package dev.photosync.ui.screen;

import dev.photosync.core.config.BrowserSettings;
import dev.photosync.core.provider.RemoteAsset;
import dev.photosync.core.provider.ThumbnailSize;
import dev.photosync.core.timeline.TimelineBrowser;
import dev.photosync.core.timeline.TimelineSection;
import dev.photosync.core.timeline.TimelineState;
import dev.photosync.mcapi.Keys;
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.image.ThumbnailCache;
import dev.photosync.ui.widget.Button;
import dev.photosync.ui.widget.ScrollModel;

import java.time.Duration;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.FormatStyle;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Optional;

/**
 * The album, by day, from now back to whenever the player started.
 *
 * <p>The trick that makes this usable over a library of tens of thousands of
 * photos is that the backend can say how many assets each month holds without
 * sending any of them. That count gives every section its exact height before a
 * single byte of image data is fetched, so the scrollbar is honest from the
 * first frame and the browser only ever asks for the months the viewport
 * actually reaches -- the same bargain a virtual list makes on the web, made
 * against a server that was designed for it.
 *
 * <p>A month arrives already split into days by {@link TimelineBrowser}, and
 * replaces its own placeholder section in place. Scroll position is measured in
 * pixels from the top of the content, and the swap does not move it, so the
 * grid fills in around the player rather than jumping under them.
 */
public final class TimelineScreen extends PhotoSyncScreen {

    /** One section's slot in the scrollable column, measured once per layout. */
    private record Block(TimelineSection section, int top, int height, int columns, int tileSize) {

        private int gridTop(int headerHeight) {
            return top + headerHeight;
        }
    }

    private final TimelineBrowser browser;
    private final ScrollModel scroll;
    private final ThumbnailCache tiles;
    private final ThumbnailCache detail;

    private final DateTimeFormatter dayFormat =
            DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault());
    private final DateTimeFormatter monthFormat =
            DateTimeFormatter.ofPattern("LLLL yyyy", Locale.getDefault());

    private Rect grid = Rect.EMPTY;
    private List<Block> blocks = List.of();
    private int measuredRevision = -1;
    private int measuredWidth = -1;
    private int measuredTileSize = -1;
    private int headerHeight = 14;

    private RemoteAsset opened;
    private Button albumToggle;

    public TimelineScreen(PhotoSyncUi ui) {
        super(ui);
        this.browser = ui.core().browser();
        this.scroll = new ScrollModel(ui.chrome());
        BrowserSettings settings = ui.core().config().current().browser();
        this.tiles = new ThumbnailCache(ui.bridge().textures(), ui.core().thumbnails(),
                ThumbnailSize.GRID, settings.thumbnailCacheEntries());
        // Only one photo is ever open, and these are full-resolution textures --
        // a 4K screenshot is 33MB on the GPU. Two, so re-opening the last one is
        // instant, and no more than that.
        this.detail = new ThumbnailCache(ui.bridge().textures(), ui.core().thumbnails(), ThumbnailSize.DETAIL, 2);
    }

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

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

    @Override
    protected void layoutBody(Rect area) {
        // Opens the album on first sight of this screen, and is a no-op on every
        // resize and tab switch after that.
        ui.ensureBrowsing();
        int gap = theme().gap();
        Rect actions = area.bottom(theme().controlHeight());
        grid = area.dropBottom(theme().controlHeight() + gap);
        scroll.viewport(grid);
        // A resize invalidates the column count, so re-measure on the next frame.
        measuredWidth = -1;

        int half = (actions.width() - gap) / 2;
        albumToggle = widgets.add(new Button(chrome, albumLabel(), () -> {
            ui.browsingLibrary(!ui.browsingLibrary());
            albumToggle.label(albumLabel());
        }));
        albumToggle.bounds(actions.left(half));

        Button refresh = widgets.add(new Button(chrome, chrome.translate("photosync.browse.refresh"),
                () -> {
                    tiles.clear();
                    browser.reload();
                }));
        refresh.bounds(actions.right(half));
    }

    private String albumLabel() {
        return chrome.translate(ui.browsingLibrary()
                ? "photosync.browse.showing_library"
                : "photosync.browse.showing_album");
    }

    /**
     * Rebuilds the column when something that changes its shape has changed:
     * new sections, a resize, or a new tile size.
     */
    private void measure(RenderBridge render) {
        BrowserSettings settings = ui.core().config().current().browser();
        int tileSize = settings.tileSize();
        int revision = browser.revision();
        int usable = grid.width() - scroll.gutter();
        if (revision == measuredRevision && usable == measuredWidth && tileSize == measuredTileSize) {
            return;
        }
        measuredRevision = revision;
        measuredWidth = usable;
        measuredTileSize = tileSize;
        tiles.capacity(settings.thumbnailCacheEntries());
        headerHeight = render.lineHeight() + 6;

        int tileGap = theme().tileGap();
        int columns = Math.max(1, (usable + tileGap) / (tileSize + tileGap));
        List<Block> built = new ArrayList<>();
        int top = 0;
        for (TimelineSection section : browser.sections()) {
            int rows = Math.max(1, (section.assetCount() + columns - 1) / columns);
            int height = headerHeight + rows * (tileSize + tileGap);
            built.add(new Block(section, top, height, columns, tileSize));
            top += height;
        }
        blocks = List.copyOf(built);
        scroll.contentHeight(top);
    }

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

    @Override
    protected void renderBody(RenderBridge render, int mouseX, int mouseY) {
        measure(render);
        scroll.advance(System.currentTimeMillis());
        chrome.well(render, grid);

        if (renderState(render)) {
            renderGrid(render, mouseX, mouseY);
            scroll.render(render, mouseX, mouseY);
        }
        if (opened != null) {
            renderOpened(render, mouseX, mouseY);
        }
    }

    /** Draws whatever stands in for the grid, and says whether the grid itself should be drawn. */
    private boolean renderState(RenderBridge render) {
        switch (browser.state()) {
            case NOT_CONFIGURED -> {
                chrome.notice(render, grid, chrome.translate("photosync.browse.not_configured"),
                        chrome.translate("photosync.browse.not_configured.hint"));
                return false;
            }
            case LOADING -> {
                chrome.notice(render, grid.dropBottom(render.lineHeight() + 8),
                        chrome.translate("photosync.browse.loading"), "");
                chrome.busyBar(render, new Rect(grid.centerX() - 60, grid.centerY() + 8, 120, 3),
                        System.currentTimeMillis(), theme().accent());
                return false;
            }
            case FAILED -> {
                chrome.notice(render, grid, chrome.translate("photosync.browse.failed"),
                        browser.error().orElse(""));
                return false;
            }
            case EMPTY -> {
                chrome.notice(render, grid, chrome.translate("photosync.browse.empty"), "");
                return false;
            }
            case READY -> {
                return !blocks.isEmpty();
            }
            default -> {
                return false;
            }
        }
    }

    private void renderGrid(RenderBridge render, int mouseX, int mouseY) {
        tiles.beginFrame();
        int offset = scroll.offset();
        // One viewport of lookahead, so a month is already being fetched by the
        // time the player scrolls it into view.
        int prefetchTop = offset - grid.height();
        int prefetchBottom = offset + grid.height() * 2;

        render.pushClip(grid.x(), grid.y(), grid.width(), grid.height());
        for (int i = firstVisible(prefetchTop); i < blocks.size(); i++) {
            Block block = blocks.get(i);
            if (block.top() > prefetchBottom) {
                break;
            }
            if (block.section() instanceof TimelineSection.PendingMonth pending) {
                browser.request(pending.bucket());
            }
            boolean onScreen = block.top() + block.height() >= offset && block.top() <= offset + grid.height();
            if (onScreen) {
                renderBlock(render, block, offset, mouseX, mouseY);
            }
        }
        render.popClip();
        tiles.endFrame();
    }

    private void renderBlock(RenderBridge render, Block block, int offset, int mouseX, int mouseY) {
        int y = grid.y() + block.top() - offset;
        TimelineSection section = block.section();
        TimelineSection.PendingMonth pending =
                section instanceof TimelineSection.PendingMonth month ? month : null;

        render.fill(grid.x(), y, grid.width() - scroll.gutter(), headerHeight, theme().fade(theme().surface(), 0.5f));
        chrome.label(render, headerLabel(section, pending != null), grid.x() + 4, y + 3, theme().textMuted());
        String count = String.valueOf(section.assetCount());
        render.text(count, grid.x() + grid.width() - scroll.gutter() - render.textWidth(count) - 4, y + 3,
                theme().textFaint(), false);

        int tileGap = theme().tileGap();
        int tileSize = block.tileSize();
        List<RemoteAsset> assets = section.assets();
        for (int index = 0; index < section.assetCount(); index++) {
            int column = index % block.columns();
            int row = index / block.columns();
            Rect tile = new Rect(
                    grid.x() + column * (tileSize + tileGap),
                    y + headerHeight + row * (tileSize + tileGap),
                    tileSize, tileSize);
            if (tile.bottom() < grid.y() || tile.y() > grid.bottom()) {
                continue;
            }
            if (index < assets.size()) {
                renderTile(render, assets.get(index), tile, tile.contains(mouseX, mouseY));
            } else {
                // A month whose page has not arrived: the count is exact, so the
                // space is already correct and only the picture is missing.
                render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
            }
        }
        if (pending != null && browser.pageError(pending.bucket()).isPresent()) {
            chrome.label(render, chrome.translate("photosync.browse.page_failed"),
                    grid.x() + 4, y + headerHeight + 2, theme().danger());
        }
    }

    private void renderTile(RenderBridge render, RemoteAsset asset, Rect tile, boolean hovered) {
        render.fill(tile.x(), tile.y(), tile.width(), tile.height(), theme().tilePlaceholder());
        Optional<ThumbnailCache.Thumbnail> thumbnail = tiles.of(asset);
        thumbnail.ifPresent(value -> drawCropped(render, value.texture(), tile));
        if (thumbnail.isEmpty() && tiles.failed(asset.id())) {
            chrome.centered(render, "!", tile, theme().danger());
        }
        if (asset.isVideo() && ui.core().config().current().browser().showVideoBadge()) {
            chrome.videoMarker(render, tile, formatDuration(asset.duration()));
        }
        if (hovered) {
            render.border(tile.x(), tile.y(), tile.width(), tile.height(), theme().accent());
        }
    }

    /** Centre-crops to a square. Squashing a landscape shot into a tile looks broken. */
    private void drawCropped(RenderBridge render, TextureHandle texture, Rect tile) {
        float aspect = texture.width() / (float) Math.max(1, texture.height());
        float half = 0.5f;
        float uHalf = aspect > 1 ? half / aspect : half;
        float vHalf = aspect > 1 ? half : half * aspect;
        render.image(texture, tile.x(), tile.y(), tile.width(), tile.height(),
                half - uHalf, half - vHalf, half + uHalf, half + vHalf);
    }

    /**
     * The opened photo, at whatever resolution has arrived so far.
     *
     * <p>What arrives first is the ThumbHash: a blurred 32x32 that is right for a
     * tile and nowhere near enough for a full-screen view. It is still worth
     * drawing, because it says which photo is opening, but it is dimmed and
     * labelled while it stands in -- an unannotated blur is indistinguishable
     * from a mod that fetched the wrong size.
     */
    private void renderOpened(RenderBridge render, int mouseX, int mouseY) {
        detail.beginFrame();
        Rect area = body();
        render.fill(area.x(), area.y(), area.width(), area.height(), theme().overlay());
        Rect frame = area.inset(6);
        Optional<ThumbnailCache.Thumbnail> image = detail.of(opened);

        if (detail.failed(opened.id())) {
            chrome.notice(render, frame, chrome.translate("photosync.browse.open_failed"),
                    chrome.translate("photosync.browse.open_failed.hint"));
        } else if (image.isEmpty()) {
            chrome.notice(render, frame, chrome.translate("photosync.browse.opening"), "");
        } else {
            drawContained(render, image.get().texture(), frame);
            if (image.get().placeholder()) {
                render.fill(frame.x(), frame.y(), frame.width(), frame.height(),
                        theme().fade(theme().overlay(), 0.6f));
                chrome.centered(render, chrome.translate("photosync.browse.opening"),
                        new Rect(frame.x(), frame.centerY() - render.lineHeight(), frame.width(), render.lineHeight()),
                        theme().text());
                chrome.busyBar(render, new Rect(frame.centerX() - 60, frame.centerY() + 6, 120, 3),
                        System.currentTimeMillis(), theme().accent());
            }
        }

        chrome.centered(render, chrome.translate("photosync.browse.close_hint"),
                area.bottom(render.lineHeight() + 2), theme().textFaint());
        detail.endFrame();
    }

    /** Fits the whole image inside {@code area}, preserving its shape. */
    private void drawContained(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 String headerLabel(TimelineSection section, boolean pending) {
        LocalDate date = section.date();
        return pending ? monthFormat.format(date) : dayFormat.format(date);
    }

    private String formatDuration(Duration duration) {
        if (duration.isZero() || duration.isNegative()) {
            return "";
        }
        long total = duration.getSeconds();
        return String.format(Locale.ROOT, "%d:%02d", total / 60, total % 60);
    }

    /** Binary search for the first block whose bottom edge is at or past {@code y}. */
    private int firstVisible(int y) {
        int low = 0;
        int high = blocks.size() - 1;
        int found = blocks.size();
        while (low <= high) {
            int mid = (low + high) >>> 1;
            Block block = blocks.get(mid);
            if (block.top() + block.height() >= y) {
                found = mid;
                high = mid - 1;
            } else {
                low = mid + 1;
            }
        }
        return found;
    }

    @Override
    protected String footerText() {
        if (browser.state() != TimelineState.READY) {
            return "";
        }
        return chrome.translate("photosync.browse.status", browser.assetCount(), blocks.size());
    }

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

    @Override
    public boolean mouseClicked(double mouseX, double mouseY, int button) {
        if (opened != null) {
            closeOpened();
            return true;
        }
        if (scroll.mouseClicked(mouseX, mouseY, button)) {
            return true;
        }
        if (button == 0 && grid.contains(mouseX, mouseY)) {
            assetAt(mouseX, mouseY).ifPresent(asset -> opened = asset);
            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) {
        int step = measuredTileSize > 0 ? measuredTileSize + theme().tileGap() : theme().rowHeight();
        return scroll.mouseScrolled(mouseX, mouseY, amount, step)
                || super.mouseScrolled(mouseX, mouseY, amount);
    }

    @Override
    public boolean keyPressed(int key, int scanCode, int modifiers) {
        if (opened != null && key == Keys.ESCAPE) {
            closeOpened();
            return true;
        }
        return switch (key) {
            case Keys.PAGE_UP -> {
                scroll.scrollBy(-grid.height());
                yield true;
            }
            case Keys.PAGE_DOWN -> {
                scroll.scrollBy(grid.height());
                yield true;
            }
            case Keys.HOME -> {
                scroll.scrollTo(0);
                yield true;
            }
            case Keys.END -> {
                scroll.scrollTo(scroll.maxOffset());
                yield true;
            }
            default -> super.keyPressed(key, scanCode, modifiers);
        };
    }

    /**
     * A cache entry that failed stays failed, which is right for a grid tile the
     * player is scrolling past and wrong for the one photo they chose to open. It
     * holds three entries, so dropping them all is the cheapest way to let a
     * second click try again.
     */
    private void closeOpened() {
        if (detail.failed(opened.id())) {
            detail.clear();
        }
        opened = null;
    }

    /** Escape dismisses the open photo before it dismisses the screen. */
    @Override
    public boolean closeOnEscape() {
        return opened == null;
    }

    private Optional<RemoteAsset> assetAt(double mouseX, double mouseY) {
        int y = (int) (mouseY - grid.y()) + scroll.offset();
        int index = firstVisible(y);
        if (index >= blocks.size()) {
            return Optional.empty();
        }
        Block block = blocks.get(index);
        int withinGrid = y - block.gridTop(headerHeight);
        if (withinGrid < 0) {
            return Optional.empty();
        }
        int stride = block.tileSize() + theme().tileGap();
        int column = (int) (mouseX - grid.x()) / stride;
        int row = withinGrid / stride;
        if (column < 0 || column >= block.columns()) {
            return Optional.empty();
        }
        int position = row * block.columns() + column;
        List<RemoteAsset> assets = block.section().assets();
        return position >= 0 && position < assets.size() ? Optional.of(assets.get(position)) : Optional.empty();
    }

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