package dev.photosync.core.upload;

import dev.photosync.core.config.UploadSettings;
import dev.photosync.core.provider.PhotoProvider;
import dev.photosync.core.provider.ProviderException;
import dev.photosync.core.provider.ProviderSession;
import dev.photosync.core.provider.TransferCancelledException;
import dev.photosync.core.provider.UploadReceipt;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.nio.file.Files;
import java.time.Duration;
import java.time.Instant;
import java.util.Optional;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;

/**
 * Drains the {@link UploadQueue}.
 *
 * <p>A single pump thread wakes a few times a second, works out how many slots
 * the current settings allow, and hands that many due jobs to a small worker
 * pool. Nothing has to be reconfigured when the player changes the concurrency:
 * the next tick simply reads the new number. The same is true of credentials --
 * while none are set the pump finds no provider and the queue just waits, which
 * is exactly the behaviour a first-run player wants.
 *
 * <p>Threads are daemons. Correctness across a hard shutdown comes from the
 * queue being durable, not from holding the JVM open; blocking exit on a stuck
 * socket would be a much worse bug than re-uploading one file.
 */
@Slf4j
public final class UploadCoordinator implements AutoCloseable {

    private static final Duration PUMP_INTERVAL = Duration.ofMillis(250);
    private static final Duration MAX_BACKOFF = Duration.ofMinutes(10);

    private final UploadQueue queue;
    private final ProviderSession session;
    private final Supplier<UploadSettings> settings;

    private final ScheduledExecutorService pump;
    private final ExecutorService workers;
    private final AtomicInteger inFlight = new AtomicInteger();

    private volatile boolean running;

    public UploadCoordinator(UploadQueue queue, ProviderSession session, Supplier<UploadSettings> settings) {
        this.queue = queue;
        this.session = session;
        this.settings = settings;
        this.pump = Executors.newSingleThreadScheduledExecutor(named("photosync-pump"));
        this.workers = Executors.newFixedThreadPool(UploadSettings.MAX_CONCURRENCY, named("photosync-upload"));
    }

    public void start() {
        running = true;
        pump.scheduleWithFixedDelay(this::tick, 0, PUMP_INTERVAL.toMillis(), TimeUnit.MILLISECONDS);
    }

    /** Nudges the pump so a just-taken screenshot does not sit for a quarter of a second. */
    public void wake() {
        if (running) {
            pump.execute(this::tick);
        }
    }

    /**
     * Stops taking new work and waits for the transfers already in flight.
     *
     * @return true if everything finished within the timeout
     */
    public boolean shutdown(Duration timeout) {
        running = false;
        pump.shutdown();
        workers.shutdown();
        try {
            return workers.awaitTermination(timeout.toMillis(), TimeUnit.MILLISECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }

    @Override
    public void close() {
        if (!shutdown(Duration.ofSeconds(5))) {
            // Whatever is still running will be re-tried next launch: the queue
            // file already says these jobs were interrupted.
            workers.shutdownNow();
            pump.shutdownNow();
        }
    }

    // -----------------------------------------------------------------------

    private void tick() {
        if (!running) {
            return;
        }
        Optional<PhotoProvider> provider = session.provider();
        if (provider.isEmpty()) {
            return;
        }
        UploadSettings current = settings.get();
        Instant now = Instant.now();
        while (running && inFlight.get() < current.concurrency()) {
            Optional<UploadJob> claimed = queue.claim(now);
            if (claimed.isEmpty()) {
                return;
            }
            inFlight.incrementAndGet();
            UploadJob job = claimed.get();
            try {
                workers.execute(() -> transfer(job, provider.get(), current));
            } catch (RuntimeException e) {
                // Pool already shutting down; put the job back for next launch.
                inFlight.decrementAndGet();
                queue.reschedule(job.id(), "Shutting down", now);
                return;
            }
        }
    }

    private void transfer(UploadJob job, PhotoProvider provider, UploadSettings current) {
        try {
            UploadReceipt receipt = provider.upload(job.toRequest(), (sent, total) -> {
                if (queue.isCancelRequested(job.id())) {
                    throw new TransferCancelledException(job.id());
                }
                queue.reportProgress(job.id(), sent, total);
            });
            queue.succeed(job.id(), receipt);
            if (current.deleteLocalAfterUpload()) {
                removeLocalCopy(job);
            }
        } catch (TransferCancelledException e) {
            log.debug("Upload of {} cancelled by the player", job.fileName());
        } catch (ProviderException e) {
            handleFailure(job, current, e);
        } catch (RuntimeException e) {
            log.error("Unexpected error uploading {}", job.fileName(), e);
            queue.fail(job.id(), e.toString());
        } finally {
            inFlight.decrementAndGet();
        }
    }

    private void handleFailure(UploadJob job, UploadSettings current, ProviderException failure) {
        String message = failure.getMessage() == null ? failure.kind().name() : failure.getMessage();
        boolean tryAgain = failure.isRetryable() && job.attempts() < current.maxAttempts();
        if (tryAgain) {
            Duration wait = backoff(job.attempts(), current);
            log.info("Upload of {} failed ({}); retrying in {}s", job.fileName(), message, wait.toSeconds());
            queue.reschedule(job.id(), message, Instant.now().plus(wait));
        } else {
            log.warn("Giving up on {}: {}", job.fileName(), message);
            queue.fail(job.id(), message);
        }
    }

    /** Exponential, so a server that is down for a while stops being hammered. */
    private static Duration backoff(int attempts, UploadSettings current) {
        int exponent = Math.min(attempts - 1, 16);
        long seconds = (long) current.retryBackoffSeconds() << exponent;
        return seconds >= MAX_BACKOFF.toSeconds() ? MAX_BACKOFF : Duration.ofSeconds(seconds);
    }

    private static void removeLocalCopy(UploadJob job) {
        try {
            Files.deleteIfExists(job.path());
        } catch (IOException e) {
            log.warn("Uploaded {} but could not delete the local copy", job.fileName(), e);
        }
    }

    private static ThreadFactory named(String prefix) {
        AtomicInteger counter = new AtomicInteger();
        return runnable -> {
            Thread thread = new Thread(runnable, prefix + "-" + counter.incrementAndGet());
            thread.setDaemon(true);
            return thread;
        };
    }
}
