package com.r35157.assetaz.services.ticker.impl.ref;

import com.r35157.assetaz.services.ticker.PriceObservation;
import com.r35157.assetaz.services.ticker.plugins.pricesource.PriceSink;
import com.r35157.assetaz.services.ticker.plugins.pricesource.PriceSource;
import com.r35157.assetaz.services.ticker.TickerService;
import com.r35157.libs.valuetypes.basic.AssetPrice;
import com.r35157.libs.valuetypes.basic.TradingPair;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.BufferedReader;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.time.format.ResolverStyle;
import java.util.ArrayList;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;

public final class TickerServiceImpl implements TickerService, PriceSink {
    public TickerServiceImpl(PriceSource... priceSources) {
        this(DATA_ROOT);

        for (PriceSource priceSource : priceSources) {
            addPriceSource(priceSource);
        }
    }

    TickerServiceImpl(@NotNull Path dataRoot) {
        this.dataRoot = Objects.requireNonNull(dataRoot, "dataRoot");
    }

    @Override
    public synchronized void start() {
        if (state != LifecycleState.NEW && state != LifecycleState.STOPPED) {
            throw new IllegalStateException("Ticker service is already starting or started");
        }

        state = LifecycleState.STARTING;
        latestByPair.clear();
        registrationsByKey.values().forEach(SourceRegistration::resetLifecycle);

        try {
            activateAndLoadHistories();
            state = LifecycleState.STARTED;
            startActiveSources();
        } catch (IOException | RuntimeException exception) {
            rollbackFailedStart(exception);
        }
    }

    @Override
    public synchronized void stop() {
        if (state == LifecycleState.NEW || state == LifecycleState.STOPPED) {
            return;
        }
        if (state != LifecycleState.STARTED) {
            throw new IllegalStateException("Ticker service cannot stop while in state " + state);
        }

        state = LifecycleState.STOPPING;
        RuntimeException failure = stopStartedSources();
        resetStoppedState();

        if (failure != null) {
            throw failure;
        }
    }

    @Override
    public synchronized @NotNull PriceObservation getLatestPrice(
            @NotNull TradingPair tradingPair
    ) {
        Objects.requireNonNull(tradingPair, "tradingPair");

        boolean registered = registrationsByKey.keySet().stream()
                .anyMatch(key -> key.tradingPair().equals(tradingPair));
        if (!registered) {
            throw new IllegalArgumentException(
                    "Ticker has no registered price source for trading pair: " + tradingPair
            );
        }

        boolean active = registrationsByKey.values().stream()
                .anyMatch(registration -> registration.active
                        && registration.key.tradingPair().equals(tradingPair));
        if (!active || state != LifecycleState.STARTED) {
            throw new IllegalStateException(
                    "Ticker has no active price source for trading pair: " + tradingPair
            );
        }

        AtomicReference<PriceObservation> latestReference = latestByPair.get(tradingPair);
        PriceObservation observation = latestReference == null ? null : latestReference.get();
        if (observation == null) {
            throw new IllegalStateException(
                    "No persisted price is available for trading pair: " + tradingPair
            );
        }

        return observation;
    }

    @Override
    public void announce(
            @NotNull PriceSource source,
            @NotNull ΩPriceΩ price,
            @NotNull Instant observedAt
    ) {
        Objects.requireNonNull(source, "source");
        Objects.requireNonNull(price, "price");
        Objects.requireNonNull(observedAt, "observedAt");
        if (observedAt.getNano() % NANOS_PER_MILLISECOND != 0) {
            throw new IllegalArgumentException(
                    "Ticker observation timestamp must have millisecond precision: " + observedAt
            );
        }

        callbackLock.readLock().lock();
        try {
            SourceRegistration registration = registrationsByInstance.get(source);
            if (registration == null) {
                throw new IllegalArgumentException("Price callback came from an unregistered source");
            }
            if (state != LifecycleState.STARTED || !registration.active) {
                throw new IllegalStateException(
                        "Price callback came from an inactive source: "
                                + registration.key.tradingPair() + " / "
                                + registration.key.sourceName()
                );
            }

            PriceObservation observation = new PriceObservation(
                    new AssetPrice(price, registration.key.tradingPair()),
                    observedAt,
                    registration.key.sourceName()
            );
            persistAndPublish(registration, observation);
        } finally {
            callbackLock.readLock().unlock();
        }
    }

    private synchronized void addPriceSource(@NotNull PriceSource priceSource) {
        Objects.requireNonNull(priceSource, "priceSource");
        if (state != LifecycleState.NEW) {
            throw new IllegalStateException(
                    "Price sources must be registered before the Ticker service is started"
            );
        }
        if (registrationsByInstance.containsKey(priceSource)) {
            throw new IllegalArgumentException("Price source instance is already registered");
        }

        TradingPair tradingPair = validateTradingPair(priceSource.getTradingPair());
        ΩPriceSourceNameΩ sourceName = validateSourceName(priceSource.getSourceName());
        SourceKey key = new SourceKey(tradingPair, sourceName);
        Path historyPath = historyPath(tradingPair, sourceName);

        if (registrationsByKey.containsKey(key)) {
            throw new IllegalArgumentException(
                    "Duplicate price source registration for " + tradingPair
                            + " source " + sourceName
                            + " addresses history " + historyPath
            );
        }

        SourceRegistration existingHistoryRegistration =
                registrationsByHistoryPath.get(historyPath);
        if (existingHistoryRegistration != null) {
            throw new IllegalArgumentException(
                    "Price source history is already registered: " + historyPath
                            + " is used by "
                            + existingHistoryRegistration.key.tradingPair()
                            + " source "
                            + existingHistoryRegistration.key.sourceName()
            );
        }

        SourceRegistration registration = new SourceRegistration(
                priceSource,
                key,
                historyPath
        );
        registrationsByKey.put(key, registration);
        registrationsByInstance.put(priceSource, registration);
        registrationsByHistoryPath.put(historyPath, registration);
    }

    private void activateAndLoadHistories() throws IOException {
        for (SourceRegistration registration : registrationsByKey.values()) {
            validateRegistrationMetadata(registration);

            if (!Files.exists(registration.historyPath)) {
                log.warn(
                        "Ticker source is inactive because its history is missing: pair={}, source={}, path={}",
                        registration.key.tradingPair(),
                        registration.key.sourceName(),
                        registration.historyPath
                );
                continue;
            }

            PriceObservation loadedLatest = loadLatestObservation(registration);
            registration.active = true;
            considerLatest(loadedLatest);
        }
    }

    private void validateRegistrationMetadata(SourceRegistration registration) {
        TradingPair currentPair = validateTradingPair(registration.source.getTradingPair());
        ΩPriceSourceNameΩ currentName = validateSourceName(registration.source.getSourceName());
        if (!registration.key.tradingPair().equals(currentPair)
                || !registration.key.sourceName().equals(currentName)) {
            throw new IllegalStateException(
                    "Registered price source identity changed before startup: expected "
                            + registration.key.tradingPair() + " / "
                            + registration.key.sourceName() + ", found "
                            + currentPair + " / " + currentName
            );
        }
    }

    private void startActiveSources() {
        for (SourceRegistration registration : registrationsByKey.values()) {
            if (!registration.active) {
                continue;
            }

            registration.started = true;
            registration.source.start(this);
        }
    }

    private void rollbackFailedStart(Exception startupFailure) {
        state = LifecycleState.STOPPING;
        RuntimeException stopFailure = stopStartedSources();
        resetStoppedState();

        IllegalStateException failure = new IllegalStateException(
                "Could not start Ticker service: " + startupFailure.getMessage(),
                startupFailure
        );
        if (stopFailure != null) {
            failure.addSuppressed(stopFailure);
        }
        throw failure;
    }

    private RuntimeException stopStartedSources() {
        // State is already STOPPING. Taking and releasing the write lock drains
        // callbacks that were accepted while STARTED. Do not hold it while a
        // source stops, because the source may wait for one of its own threads.
        callbackLock.writeLock().lock();
        try {
        } finally {
            callbackLock.writeLock().unlock();
        }

        RuntimeException failure = null;
        List<SourceRegistration> registrations = new ArrayList<>(
                registrationsByKey.values()
        );

        for (int index = registrations.size() - 1; index >= 0; index--) {
            SourceRegistration registration = registrations.get(index);
            if (!registration.started) {
                continue;
            }

            try {
                registration.source.stop();
            } catch (RuntimeException exception) {
                if (failure == null) {
                    failure = new IllegalStateException(
                            "One or more price sources failed to stop"
                    );
                }
                failure.addSuppressed(exception);
            } finally {
                registration.started = false;
            }
        }

        return failure;
    }

    private void resetStoppedState() {
        registrationsByKey.values().forEach(SourceRegistration::resetLifecycle);
        latestByPair.clear();
        state = LifecycleState.STOPPED;
    }

    private PriceObservation loadLatestObservation(
            SourceRegistration registration
    ) throws IOException {
        PriceObservation loadedLatest = null;

        try (BufferedReader reader = Files.newBufferedReader(
                registration.historyPath,
                StandardCharsets.UTF_8
        )) {
            String rawLine;
            int lineNumber = 0;

            while ((rawLine = reader.readLine()) != null) {
                lineNumber++;
                String data = removeComment(rawLine).trim();
                if (data.isEmpty()) {
                    continue;
                }

                PriceObservation observation = parseObservation(
                        registration,
                        data,
                        rawLine,
                        lineNumber
                );
                if (loadedLatest == null
                        || observation.observedAt().isAfter(loadedLatest.observedAt())) {
                    loadedLatest = observation;
                }
            }
        }

        return loadedLatest;
    }

    private PriceObservation parseObservation(
            SourceRegistration registration,
            String data,
            String rawLine,
            int lineNumber
    ) throws IOException {
        int separator = data.indexOf(':');
        if (separator <= 0 || separator != data.lastIndexOf(':')
                || separator == data.length() - 1) {
            throw malformedHistory(registration.historyPath, lineNumber, rawLine, null);
        }

        try {
            LocalDateTime localDateTime = LocalDateTime.parse(
                    data.substring(0, separator),
                    TIMESTAMP_FORMATTER
            );
            Instant observedAt = localDateTime.toInstant(ZoneOffset.UTC);
            ΩPriceΩ price = new ΩPriceΩ(data.substring(separator + 1));

            return new PriceObservation(
                    new AssetPrice(price, registration.key.tradingPair()),
                    observedAt,
                    registration.key.sourceName()
            );
        } catch (RuntimeException exception) {
            throw malformedHistory(
                    registration.historyPath,
                    lineNumber,
                    rawLine,
                    exception
            );
        }
    }

    private static IOException malformedHistory(
            Path historyPath,
            int lineNumber,
            String rawLine,
            RuntimeException cause
    ) {
        return new IOException(
                "Malformed ticker history in " + historyPath
                        + " at line " + lineNumber + ": " + rawLine,
                cause
        );
    }

    private void persistAndPublish(
            SourceRegistration registration,
            PriceObservation observation
    ) {
        registration.persistenceLock.lock();
        try {
            persist(registration.historyPath, observation);
        } catch (IOException exception) {
            log.error(
                    "Failed to persist ticker observation: pair={}, source={}, path={}; retaining prior latest observation",
                    registration.key.tradingPair(),
                    registration.key.sourceName(),
                    registration.historyPath,
                    exception
            );
            return;
        } finally {
            registration.persistenceLock.unlock();
        }

        considerLatest(observation);
    }

    private static void persist(
            Path historyPath,
            PriceObservation observation
    ) throws IOException {
        String encodedObservation = TIMESTAMP_FORMATTER.format(
                LocalDateTime.ofInstant(observation.observedAt(), ZoneOffset.UTC)
        ) + ":" + observation.price().price().toPlainString() + "\n";

        try (FileChannel channel = FileChannel.open(
                historyPath,
                StandardOpenOption.READ,
                StandardOpenOption.WRITE
        )) {
            long size = channel.size();
            boolean needsLineSeparator = size > 0
                    && !endsWithLineSeparator(channel, size, historyPath);
            channel.position(size);

            if (needsLineSeparator) {
                writeFully(channel, ByteBuffer.wrap(new byte[] {'\n'}));
            }
            writeFully(
                    channel,
                    ByteBuffer.wrap(encodedObservation.getBytes(StandardCharsets.UTF_8))
            );
            channel.force(true);
        }
    }

    private void considerLatest(PriceObservation observation) {
        if (observation == null) {
            return;
        }

        latestByPair.computeIfAbsent(
                observation.price().tradingPair(),
                ignored -> new AtomicReference<>()
        ).updateAndGet(current -> current == null
                || observation.observedAt().isAfter(current.observedAt())
                ? observation
                : current);
    }

    private Path historyPath(
            TradingPair tradingPair,
            ΩPriceSourceNameΩ sourceName
    ) {
        String filename = safeSymbol(tradingPair.base().symbol())
                + "_" + safeSymbol(tradingPair.quote().symbol())
                + ".prices";

        return dataRoot
                .resolve(tradingPair.base().id().toString())
                .resolve(tradingPair.quote().id().toString())
                .resolve(sourceName)
                .resolve(filename);
    }

    private static TradingPair validateTradingPair(TradingPair tradingPair) {
        Objects.requireNonNull(tradingPair, "priceSource.tradingPair");
        Objects.requireNonNull(tradingPair.base(), "priceSource.tradingPair.base");
        Objects.requireNonNull(tradingPair.quote(), "priceSource.tradingPair.quote");
        Objects.requireNonNull(tradingPair.base().id(), "priceSource.tradingPair.base.id");
        Objects.requireNonNull(tradingPair.quote().id(), "priceSource.tradingPair.quote.id");
        Objects.requireNonNull(tradingPair.base().symbol(), "priceSource.tradingPair.base.symbol");
        Objects.requireNonNull(tradingPair.quote().symbol(), "priceSource.tradingPair.quote.symbol");
        return tradingPair;
    }

    private static ΩPriceSourceNameΩ validateSourceName(ΩPriceSourceNameΩ sourceName) {
        Objects.requireNonNull(sourceName, "priceSource.sourceName");
        if (sourceName.isBlank()
                || sourceName.equals(".")
                || sourceName.contains("/")
                || sourceName.contains("\\")
                || sourceName.contains("..")
                || sourceName.codePoints().anyMatch(Character::isISOControl)) {
            throw new IllegalArgumentException(
                    "Unsafe price source name cannot be used as a history directory: "
                            + sourceName
            );
        }
        return sourceName;
    }

    private static String safeSymbol(String symbol) {
        if (symbol.isEmpty()) {
            throw new IllegalArgumentException("Currency symbol cannot be empty");
        }

        StringBuilder safe = new StringBuilder();
        symbol.codePoints().forEach(codePoint -> {
            if (codePoint >= 'A' && codePoint <= 'Z'
                    || codePoint >= 'a' && codePoint <= 'z'
                    || codePoint >= '0' && codePoint <= '9'
                    || codePoint == '-'
                    || codePoint == '_') {
                safe.appendCodePoint(codePoint);
            } else {
                safe.append('_');
            }
        });
        return safe.toString();
    }

    private static boolean endsWithLineSeparator(
            FileChannel channel,
            long size,
            Path historyPath
    ) throws IOException {
        ByteBuffer finalByte = ByteBuffer.allocate(1);
        channel.position(size - 1);
        if (channel.read(finalByte) != 1) {
            throw new IOException("Could not read final byte of ticker history: " + historyPath);
        }

        return finalByte.array()[0] == '\n' || finalByte.array()[0] == '\r';
    }

    private static void writeFully(FileChannel channel, ByteBuffer bytes) throws IOException {
        while (bytes.hasRemaining()) {
            channel.write(bytes);
        }
    }

    private static String removeComment(String line) {
        int commentStart = line.indexOf('#');
        return commentStart < 0 ? line : line.substring(0, commentStart);
    }

    private enum LifecycleState {
        NEW,
        STARTING,
        STARTED,
        STOPPING,
        STOPPED
    }

    private record SourceKey(
            TradingPair tradingPair,
            ΩPriceSourceNameΩ sourceName
    ) {
    }

    private static final class SourceRegistration {
        private SourceRegistration(
                PriceSource source,
                SourceKey key,
                Path historyPath
        ) {
            this.source = source;
            this.key = key;
            this.historyPath = historyPath;
        }

        private void resetLifecycle() {
            active = false;
            started = false;
        }

        private final PriceSource source;
        private final SourceKey key;
        private final Path historyPath;
        private final ReentrantLock persistenceLock = new ReentrantLock();

        private volatile boolean active;
        private boolean started;
    }

    private static final Logger log = LoggerFactory.getLogger(TickerServiceImpl.class);
    private static final Path DATA_ROOT = Path.of("data", "assetaz", "ticker");
    private static final DateTimeFormatter TIMESTAMP_FORMATTER = DateTimeFormatter
            .ofPattern("uuuuMMddHHmmssSSS'Z'", Locale.ROOT)
            .withResolverStyle(ResolverStyle.STRICT);
    private static final int NANOS_PER_MILLISECOND = 1_000_000;

    private final Path dataRoot;
    private final Map<SourceKey, SourceRegistration> registrationsByKey =
            new LinkedHashMap<>();
    private final Map<PriceSource, SourceRegistration> registrationsByInstance =
            new IdentityHashMap<>();
    private final Map<Path, SourceRegistration> registrationsByHistoryPath =
            new LinkedHashMap<>();
    private final Map<TradingPair, AtomicReference<PriceObservation>> latestByPair =
            new ConcurrentHashMap<>();
    private final ReentrantReadWriteLock callbackLock = new ReentrantReadWriteLock();

    private volatile LifecycleState state = LifecycleState.NEW;
}
