package dev.photosync.core.upload;

import com.google.gson.reflect.TypeToken;
import dev.photosync.core.capture.CapturedScreenshot;
import dev.photosync.core.persistence.JsonFile;
import dev.photosync.core.provider.AlbumRef;
import dev.photosync.core.provider.UploadReceipt;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Consumer;

/**
 * The durable list of screenshots on their way to the backend.
 *
 * <p>This is the answer to "what happens if the game dies mid-upload". Every
 * state change is written through to {@code photosync-queue.json} before it is
 * announced, so the worst a hard kill can cost is one in-flight request. On the
 * next launch anything left in {@link UploadState#UPLOADING} is demoted back to
 * {@link UploadState#PENDING} and simply tried again -- which is safe because
 * {@code PhotoProvider.upload} is required to answer
 * {@link UploadReceipt.Outcome#DUPLICATE} rather than store a second copy.
 *
 * <p>All mutation happens under one lock and all events are published outside
 * it, so a listener can call back into the queue without deadlocking.
 */
@Slf4j
public final class UploadQueue {

    private final JsonFile file;
    private final int historyLimit;

    private final Object lock = new Object();
    private final Map<String, UploadJob> jobs = new LinkedHashMap<>();

    /** jobId to {sent, total}. Not persisted: meaningless across a restart. */
    private final Map<String, long[]> transfers = new ConcurrentHashMap<>();
    private final Set<String> cancelRequests = ConcurrentHashMap.newKeySet();
    private final List<Consumer<UploadEvent>> listeners = new CopyOnWriteArrayList<>();

    public UploadQueue(Path path) {
        this(path, 100);
    }

    public UploadQueue(Path path, int historyLimit) {
        this.file = new JsonFile(path);
        this.historyLimit = historyLimit;
        restore();
    }

    public void onEvent(Consumer<UploadEvent> listener) {
        listeners.add(listener);
    }

    // -----------------------------------------------------------------------
    // Reading
    // -----------------------------------------------------------------------

    /** Oldest first, which is also the order workers pick jobs up in. */
    public List<QueuedUpload> snapshot() {
        List<UploadJob> current;
        synchronized (lock) {
            current = List.copyOf(jobs.values());
        }
        List<QueuedUpload> view = new ArrayList<>(current.size());
        for (UploadJob job : current) {
            long[] transfer = transfers.get(job.id());
            view.add(transfer == null
                    ? QueuedUpload.idle(job)
                    : new QueuedUpload(job, transfer[0], transfer[1]));
        }
        return view;
    }

    public Optional<UploadJob> find(String id) {
        synchronized (lock) {
            return Optional.ofNullable(jobs.get(id));
        }
    }

    /** How much work the quit dialog is waiting for. */
    public int activeCount() {
        synchronized (lock) {
            return (int) jobs.values().stream().filter(job -> job.state().isActive()).count();
        }
    }

    public int failedCount() {
        synchronized (lock) {
            return (int) jobs.values().stream().filter(job -> job.state() == UploadState.FAILED).count();
        }
    }

    public boolean hasActiveWork() {
        synchronized (lock) {
            return jobs.values().stream().anyMatch(job -> job.state().isActive());
        }
    }

    public boolean isCancelRequested(String jobId) {
        return cancelRequests.contains(jobId);
    }

    // -----------------------------------------------------------------------
    // Writing
    // -----------------------------------------------------------------------

    /**
     * Adds a screenshot, or returns the job already covering that file.
     *
     * <p>The de-duplication matters on the path where a capture is enqueued and
     * the player immediately quits and relaunches: the restored job and a fresh
     * rescan would otherwise both try to upload the same file.
     *
     * @param description one line of context captured with the screenshot, e.g.
     *                    client and server; persisted with the job so a retry
     *                    after a restart describes the same moment
     */
    public UploadJob enqueue(CapturedScreenshot shot, AlbumRef album, String description) {
        String absolute = shot.file().toAbsolutePath().toString();
        UploadJob created;
        synchronized (lock) {
            Optional<UploadJob> existing = jobs.values().stream()
                    .filter(job -> job.file().equals(absolute))
                    .filter(job -> job.state().isActive() || job.state() == UploadState.FAILED)
                    .findFirst();
            if (existing.isPresent()) {
                return existing.get();
            }
            created = UploadJob.builder()
                    .id(UUID.randomUUID().toString())
                    .file(absolute)
                    .fileName(shot.fileName())
                    .sizeBytes(shot.sizeBytes())
                    .capturedAt(shot.capturedAt())
                    .origin(shot.origin())
                    .albumId(album.id().orElse(""))
                    .description(description)
                    .state(UploadState.PENDING)
                    .attempts(0)
                    .build();
            jobs.put(created.id(), created);
            persist();
        }
        publish(new UploadEvent.Enqueued(created));
        return created;
    }

    /** Takes the oldest job that is due, marking it in flight. */
    public Optional<UploadJob> claim(Instant now) {
        UploadJob claimed = null;
        synchronized (lock) {
            for (UploadJob job : jobs.values()) {
                if (job.isClaimable(now)) {
                    claimed = job.toBuilder()
                            .state(UploadState.UPLOADING)
                            .attempts(job.attempts() + 1)
                            .lastError(null)
                            .build();
                    jobs.put(claimed.id(), claimed);
                    persist();
                    break;
                }
            }
        }
        if (claimed != null) {
            transfers.put(claimed.id(), new long[]{0L, claimed.sizeBytes()});
            publish(new UploadEvent.Started(claimed));
        }
        return Optional.ofNullable(claimed);
    }

    public void reportProgress(String jobId, long sent, long total) {
        transfers.put(jobId, new long[]{sent, total});
    }

    public void succeed(String jobId, UploadReceipt receipt) {
        transition(jobId, job -> job.toBuilder()
                .state(UploadState.COMPLETED)
                .assetId(receipt.assetId())
                .lastError(null)
                .build(),
                job -> new UploadEvent.Completed(job, receipt));
    }

    /** A retryable failure: the job goes back to sleep until {@code notBefore}. */
    public void reschedule(String jobId, String message, Instant notBefore) {
        transition(jobId, job -> job.toBuilder()
                .state(UploadState.RETRYING)
                .notBefore(notBefore)
                .lastError(message)
                .build(),
                job -> new UploadEvent.Failed(job, message, true));
    }

    /** A permanent failure, or the last attempt. Waits for the player now. */
    public void fail(String jobId, String message) {
        transition(jobId, job -> job.toBuilder()
                .state(UploadState.FAILED)
                .notBefore(null)
                .lastError(message)
                .build(),
                job -> new UploadEvent.Failed(job, message, false));
    }

    /** Player pressed retry: a clean slate rather than one more attempt. */
    public void retry(String jobId) {
        transition(jobId, job -> job.toBuilder()
                .state(UploadState.PENDING)
                .attempts(0)
                .notBefore(null)
                .lastError(null)
                .build(),
                UploadEvent.Enqueued::new);
    }

    public void retryAllFailed() {
        List<String> failed;
        synchronized (lock) {
            failed = jobs.values().stream()
                    .filter(job -> job.state() == UploadState.FAILED)
                    .map(UploadJob::id)
                    .toList();
        }
        failed.forEach(this::retry);
    }

    /**
     * Removes a job. An upload already in flight is asked to stop through
     * {@link #isCancelRequested(String)}, which its progress callback checks.
     */
    public void cancel(String jobId) {
        cancelRequests.add(jobId);
        transition(jobId, job -> job.toBuilder()
                .state(UploadState.CANCELLED)
                .lastError(null)
                .build(),
                UploadEvent.Removed::new);
    }

    /** Drops a finished or failed job from the list entirely. */
    public void forget(String jobId) {
        UploadJob removed;
        synchronized (lock) {
            UploadJob job = jobs.get(jobId);
            if (job == null || job.state() == UploadState.UPLOADING) {
                return;
            }
            removed = jobs.remove(jobId);
            persist();
        }
        transfers.remove(jobId);
        cancelRequests.remove(jobId);
        publish(new UploadEvent.Removed(removed));
    }

    public void clearFinished() {
        List<UploadJob> removed = new ArrayList<>();
        synchronized (lock) {
            jobs.values().removeIf(job -> {
                if (job.state().isFinished()) {
                    removed.add(job);
                    return true;
                }
                return false;
            });
            if (!removed.isEmpty()) {
                persist();
            }
        }
        for (UploadJob job : removed) {
            transfers.remove(job.id());
            cancelRequests.remove(job.id());
            publish(new UploadEvent.Removed(job));
        }
    }

    // -----------------------------------------------------------------------
    // Internals
    // -----------------------------------------------------------------------

    private void transition(String jobId,
                            java.util.function.UnaryOperator<UploadJob> change,
                            java.util.function.Function<UploadJob, UploadEvent> event) {
        UploadJob updated;
        boolean drained;
        synchronized (lock) {
            UploadJob previous = jobs.get(jobId);
            if (previous == null) {
                return;
            }
            updated = change.apply(previous);
            jobs.put(jobId, updated);
            trimHistory();
            persist();
            drained = previous.state().isActive() && jobs.values().stream().noneMatch(job -> job.state().isActive());
        }
        if (!updated.state().isActive()) {
            transfers.remove(jobId);
        }
        publish(event.apply(updated));
        if (drained) {
            publish(new UploadEvent.Drained(updated));
        }
    }

    /** Caller holds the lock. Keeps the finished tail from growing without bound. */
    private void trimHistory() {
        List<String> finished = jobs.values().stream()
                .filter(job -> job.state().isFinished())
                .map(UploadJob::id)
                .toList();
        for (int i = 0; i < finished.size() - historyLimit; i++) {
            jobs.remove(finished.get(i));
        }
    }

    /** Caller holds the lock. */
    private void persist() {
        List<UploadJob> durable = jobs.values().stream()
                .filter(job -> job.state().isPersistent())
                .toList();
        try {
            file.write(file.gson().toJsonTree(durable));
        } catch (IOException e) {
            log.error("Could not write the upload queue to {}", file.path(), e);
        }
    }

    private void restore() {
        TypeToken<List<UploadJob>> listType = new TypeToken<>() {
        };
        List<UploadJob> stored = file.readTree()
                .<List<UploadJob>>map(tree -> file.gson().fromJson(tree, listType.getType()))
                .orElseGet(List::of);

        int skipped = 0;
        for (UploadJob job : stored) {
            if (job == null || job.id() == null || job.file() == null || job.state() == null) {
                skipped++;
                continue;
            }
            if (!Files.isRegularFile(Path.of(job.file()))) {
                // The screenshot was deleted while we were away. Nothing to send.
                skipped++;
                continue;
            }
            // Anything that claimed to be uploading was interrupted by whatever
            // ended the last session; start it over.
            UploadJob resumed = job.state() == UploadState.UPLOADING
                    ? job.toBuilder().state(UploadState.PENDING).build()
                    : job;
            jobs.put(resumed.id(), resumed);
        }
        if (!jobs.isEmpty() || skipped > 0) {
            log.info("Restored {} pending upload(s) from {} ({} dropped)", jobs.size(), file.path(), skipped);
        }
    }

    private void publish(UploadEvent event) {
        for (Consumer<UploadEvent> listener : listeners) {
            try {
                listener.accept(event);
            } catch (RuntimeException e) {
                log.error("An upload listener failed on {}", event.getClass().getSimpleName(), e);
            }
        }
    }
}
