package dev.photosync.ui.screen;

import dev.photosync.core.upload.QueuedUpload;
import dev.photosync.core.upload.UploadJob;
import dev.photosync.core.upload.UploadQueue;
import dev.photosync.core.upload.UploadState;
import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.mcapi.render.TextureHandle;
import dev.photosync.ui.PhotoSyncUi;
import dev.photosync.ui.Rect;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Locale;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.function.Consumer;

/**
 * The screenshots this game knows about and what has happened to them.
 *
 * <p>This is the screen the mod is for. Everything else is setup; this is where
 * a player who has just pressed F2 looks to see that it went somewhere.
 *
 * <p>Rows are deliberately plain -- a name, a state, a bar. The preview lives in
 * a single pane on the right, decoded one at a time, because a screenshot is a
 * full-resolution PNG and a column of them would be tens of megabytes of texture
 * for a list the player scrolls past in two seconds.
 */
@Slf4j
public final class QueueScreen extends PhotoSyncScreen {

    private static final int DETAIL_WIDTH = 140;
    private static final int MIN_WIDTH_FOR_DETAIL = 340;

    private final UploadQueue queue;
    private final ScrollModel scroll;
    private final Preview preview = new Preview();

    private Rect listArea = Rect.EMPTY;
    private Rect detailArea = Rect.EMPTY;

    private String selectedId;
    private List<QueuedUpload> rows = List.of();

    private Button retryAll;
    private Button clearFinished;
    private Button retryOne;
    private Button cancelOne;
    private Button revealOne;
    private Button forgetOne;

    public QueueScreen(PhotoSyncUi ui) {
        super(ui);
        this.queue = ui.core().queue();
        this.scroll = new ScrollModel(ui.chrome());
    }

    @Override
    protected Tab tab() {
        return Tab.QUEUE;
    }

    // -----------------------------------------------------------------------
    // Layout
    // -----------------------------------------------------------------------

    @Override
    protected void layoutBody(Rect area) {
        int gap = theme().gap();
        Rect actions = area.bottom(theme().controlHeight());
        Rect content = area.dropBottom(theme().controlHeight() + gap);

        boolean roomForDetail = content.width() >= MIN_WIDTH_FOR_DETAIL;
        detailArea = roomForDetail ? content.right(DETAIL_WIDTH) : Rect.EMPTY;
        listArea = roomForDetail ? content.dropRight(DETAIL_WIDTH + gap) : content;
        scroll.viewport(listArea);

        retryAll = widgets.add(new Button(chrome, chrome.translate("photosync.queue.retry_all"),
                queue::retryAllFailed));
        retryAll.bounds(actions.left(actions.width() / 2 - gap / 2));

        clearFinished = widgets.add(new Button(chrome, chrome.translate("photosync.queue.clear_finished"),
                queue::clearFinished));
        clearFinished.bounds(actions.right(actions.width() / 2 - gap / 2));

        layoutDetailButtons();
    }

    private void layoutDetailButtons() {
        if (detailArea.isEmpty()) {
            retryOne = null;
            cancelOne = null;
            revealOne = null;
            forgetOne = null;
            return;
        }
        int gap = theme().gap();
        int height = theme().controlHeight();
        int half = (detailArea.width() - 8 - gap) / 2;
        Rect column = detailArea.inset(4);
        Rect first = new Rect(column.x(), column.bottom() - height * 2 - gap, column.width(), height);
        Rect second = new Rect(column.x(), column.bottom() - height, column.width(), height);

        retryOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.retry"),
                () -> withSelection(job -> queue.retry(job.id()))));
        retryOne.bounds(first.left(half));

        cancelOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.cancel"),
                () -> withSelection(job -> queue.cancel(job.id()))));
        cancelOne.emphasized(Button.Emphasis.DANGER).bounds(first.right(half));

        revealOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.reveal"),
                () -> withSelection(job -> ui.game().reveal(job.path()))));
        revealOne.bounds(second.left(half));

        forgetOne = widgets.add(new Button(chrome, chrome.translate("photosync.queue.forget"),
                () -> withSelection(job -> {
                    queue.forget(job.id());
                    select(null);
                })));
        forgetOne.bounds(second.right(half));
    }

    /** The detail buttons all act on whatever is selected, or on nothing at all. */
    private void withSelection(Consumer<UploadJob> action) {
        selected().map(QueuedUpload::job).ifPresent(action);
    }

    // -----------------------------------------------------------------------
    // Rendering
    // -----------------------------------------------------------------------

    @Override
    protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
        // Newest first, matching the browser -- the screenshot you just took is
        // the one you are looking for. (Not List::reversed: shared code targets
        // Java 17, because 1.20 through 1.20.4 run on a Java 17 JVM.)
        List<QueuedUpload> ordered = new ArrayList<>(queue.snapshot());
        Collections.reverse(ordered);
        rows = ordered;
        scroll.contentHeight(rows.size() * theme().rowHeight());
        scroll.advance(System.currentTimeMillis());
        updateButtons();

        chrome.well(render, listArea);
        if (rows.isEmpty()) {
            chrome.notice(render, listArea, chrome.translate("photosync.queue.empty"),
                    chrome.translate("photosync.queue.empty.hint"));
        } else {
            renderRows(render, mouseX, mouseY);
        }
        scroll.render(render, mouseX, mouseY);

        if (!detailArea.isEmpty()) {
            renderDetail(render);
        }
    }

    private void renderRows(RenderBridge render, int mouseX, int mouseY) {
        int rowHeight = theme().rowHeight();
        int gutter = scroll.gutter();
        render.pushClip(listArea.x(), listArea.y(), listArea.width(), listArea.height());
        int first = Math.max(0, scroll.offset() / rowHeight);
        int last = Math.min(rows.size(), (scroll.offset() + listArea.height()) / rowHeight + 1);
        for (int i = first; i < last; i++) {
            Rect bounds = new Rect(listArea.x(), listArea.y() + i * rowHeight - scroll.offset(),
                    listArea.width() - gutter, rowHeight);
            renderRow(render, rows.get(i), bounds, bounds.contains(mouseX, mouseY));
        }
        render.popClip();
    }

    private void renderRow(RenderBridge render, QueuedUpload row, Rect bounds, boolean hovered) {
        UploadJob job = row.job();
        boolean selected = job.id().equals(selectedId);
        if (selected || hovered) {
            render.fill(bounds.x(), bounds.y(), bounds.width(), bounds.height(),
                    selected ? theme().surface() : theme().surfaceHover());
        }
        if (selected) {
            render.fill(bounds.x(), bounds.y(), 1, bounds.height(), theme().accent());
        }

        Rect inner = bounds.inset(5, 3, 5, 3);
        String badge = chrome.translate(stateKey(job.state()));
        int badgeWidth = render.textWidth(badge) + 6;
        chrome.badge(render, badge, inner.right() - badgeWidth, inner.y(),
                theme().fade(stateColour(job.state()), 0.25f), stateColour(job.state()));

        render.text(chrome.elide(render, job.fileName(), inner.width() - badgeWidth - 6),
                inner.x(), inner.y() + 2, theme().text(), false);

        Rect secondLine = new Rect(inner.x(), inner.y() + render.lineHeight() + 3, inner.width(), 4);
        switch (job.state()) {
            case UPLOADING -> chrome.progressBar(render, secondLine, row.fraction(), theme().accent());
            case PENDING -> chrome.progressBar(render, secondLine, 0, theme().accent());
            case RETRYING -> chrome.busyBar(render, secondLine, System.currentTimeMillis(), theme().warning());
            default -> renderRowMessage(render, job, secondLine);
        }
    }

    private void renderRowMessage(RenderBridge render, UploadJob job, Rect line) {
        String message = job.failureMessage().orElseGet(() -> formatSize(job.sizeBytes()));
        int colour = job.state() == UploadState.FAILED ? theme().danger() : theme().textFaint();
        render.text(chrome.elide(render, message, line.width()), line.x(), line.y() - 2, colour, false);
    }

    private void renderDetail(RenderBridge render) {
        chrome.well(render, detailArea);
        Optional<QueuedUpload> selection = selected();
        if (selection.isEmpty()) {
            chrome.notice(render, detailArea, chrome.translate("photosync.queue.no_selection"), "");
            return;
        }
        UploadJob job = selection.get().job();
        preview.follow(job.path());

        Rect inner = detailArea.inset(4);
        int line = render.lineHeight() + 2;
        // Square, but never so tall that it pushes the buttons off the panel.
        int side = Math.max(24, Math.min(inner.width(),
                inner.height() - theme().controlHeight() * 2 - theme().gap() - line * 5));
        Rect image = new Rect(inner.x(), inner.y(), inner.width(), side);
        render.fill(image.x(), image.y(), image.width(), image.height(), theme().tilePlaceholder());
        preview.texture().ifPresentOrElse(
                texture -> drawFitted(render, texture, image),
                () -> chrome.notice(render, image, chrome.translate(preview.failed()
                        ? "photosync.queue.preview_failed"
                        : "photosync.queue.preview_loading"), ""));

        int y = image.bottom() + 4;
        render.text(chrome.elide(render, job.fileName(), inner.width()), inner.x(), y, theme().text(), false);
        render.text(formatSize(job.sizeBytes()), inner.x(), y + line, theme().textFaint(), false);
        job.failureMessage().ifPresent(message -> {
            List<String> lines = chrome.wrap(render, message, inner.width(), 3);
            for (int i = 0; i < lines.size(); i++) {
                render.text(lines.get(i), inner.x(), y + line * (i + 2), theme().danger(), false);
            }
        });
    }

    /** Letterboxes rather than stretches: a squashed screenshot is worse than a border. */
    private void drawFitted(RenderBridge render, TextureHandle texture, Rect area) {
        double scale = Math.min(area.width() / (double) texture.width(), area.height() / (double) texture.height());
        int width = Math.max(1, (int) Math.round(texture.width() * scale));
        int height = Math.max(1, (int) Math.round(texture.height() * scale));
        render.image(texture, area.centerX() - width / 2, area.centerY() - height / 2, width, height);
    }

    private void updateButtons() {
        Optional<UploadState> state = selected().map(row -> row.job().state());
        retryAll.enabled(queue.failedCount() > 0);
        clearFinished.enabled(rows.stream().anyMatch(row -> row.job().state().isFinished()));
        if (retryOne == null) {
            return;
        }
        retryOne.enabled(state.filter(value -> value == UploadState.FAILED).isPresent());
        cancelOne.enabled(state.filter(UploadState::isActive).isPresent());
        revealOne.enabled(state.isPresent());
        forgetOne.enabled(state.filter(value -> !value.isActive()).isPresent());
    }

    @Override
    protected String footerText() {
        int active = queue.activeCount();
        int failed = queue.failedCount();
        if (active == 0 && failed == 0) {
            return chrome.translate("photosync.queue.idle");
        }
        return chrome.translate("photosync.queue.status", active, failed);
    }

    // -----------------------------------------------------------------------
    // Input
    // -----------------------------------------------------------------------

    @Override
    public boolean mouseClicked(double mouseX, double mouseY, int button) {
        if (scroll.mouseClicked(mouseX, mouseY, button)) {
            return true;
        }
        if (button == 0 && listArea.contains(mouseX, mouseY)) {
            int index = (int) ((mouseY - listArea.y() + scroll.offset()) / theme().rowHeight());
            select(index >= 0 && index < rows.size() ? rows.get(index).job().id() : null);
            return true;
        }
        return super.mouseClicked(mouseX, mouseY, button);
    }

    @Override
    public boolean mouseReleased(double mouseX, double mouseY, int button) {
        return scroll.mouseReleased() || super.mouseReleased(mouseX, mouseY, button);
    }

    @Override
    public boolean mouseDragged(double mouseX, double mouseY, int button, double deltaX, double deltaY) {
        return scroll.mouseDragged(mouseX, mouseY, button)
                || super.mouseDragged(mouseX, mouseY, button, deltaX, deltaY);
    }

    @Override
    public boolean mouseScrolled(double mouseX, double mouseY, double amount) {
        return scroll.mouseScrolled(mouseX, mouseY, amount, theme().rowHeight())
                || super.mouseScrolled(mouseX, mouseY, amount);
    }

    @Override
    public void closed() {
        preview.close();
    }

    private Optional<QueuedUpload> selected() {
        return rows.stream().filter(row -> row.job().id().equals(selectedId)).findFirst();
    }

    private void select(String jobId) {
        if (!Objects.equals(selectedId, jobId)) {
            selectedId = jobId;
            preview.reset();
        }
    }

    private String stateKey(UploadState state) {
        return "photosync.state." + state.name().toLowerCase(Locale.ROOT);
    }

    private int stateColour(UploadState state) {
        return switch (state) {
            case COMPLETED -> theme().success();
            case FAILED -> theme().danger();
            case RETRYING -> theme().warning();
            case CANCELLED -> theme().textFaint();
            case PENDING, UPLOADING -> theme().accent();
        };
    }

    private String formatSize(long bytes) {
        if (bytes < 1024) {
            return bytes + " B";
        }
        if (bytes < 1024 * 1024) {
            return String.format(Locale.ROOT, "%.1f KB", bytes / 1024.0);
        }
        return String.format(Locale.ROOT, "%.1f MB", bytes / (1024.0 * 1024.0));
    }

    /**
     * The one decoded screenshot on screen.
     *
     * <p>The file is read on a worker and decoded on the render thread, because
     * only the render thread may make a texture and only a worker should touch a
     * disk. Exactly one texture is alive at a time.
     */
    private final class Preview implements AutoCloseable {

        private Path source;
        private CompletableFuture<byte[]> reading;
        private TextureHandle texture;
        private boolean failed;

        /** Called every frame with the selected file; only acts when it changes. */
        private void follow(Path path) {
            if (!path.equals(source)) {
                close();
                source = path;
                reading = CompletableFuture.supplyAsync(() -> readAll(path));
            }
            poll();
        }

        private void poll() {
            if (reading == null || !reading.isDone()) {
                return;
            }
            CompletableFuture<byte[]> finished = reading;
            reading = null;
            try {
                texture = ui.bridge().textures().decode(finished.join());
            } catch (IOException | RuntimeException e) {
                log.debug("Cannot preview {}", source, e);
                failed = true;
            }
        }

        private Optional<TextureHandle> texture() {
            return Optional.ofNullable(texture);
        }

        private boolean failed() {
            return failed;
        }

        /** Forgets the current image so the next {@link #follow} reloads. */
        private void reset() {
            close();
            source = null;
        }

        @Override
        public void close() {
            if (reading != null) {
                reading.cancel(false);
                reading = null;
            }
            if (texture != null) {
                texture.close();
                texture = null;
            }
            failed = false;
        }

        private byte[] readAll(Path path) {
            try {
                return Files.readAllBytes(path);
            } catch (IOException e) {
                throw new CompletionException(e);
            }
        }
    }
}
