package dev.photosync.ui.widget;

import dev.photosync.mcapi.render.RenderBridge;
import dev.photosync.ui.Chrome;
import dev.photosync.ui.Rect;
import dev.photosync.ui.Theme;
import lombok.Getter;
import lombok.experimental.Accessors;

/**
 * A vertically scrolling viewport: how far down it is, how far down it may go,
 * and the bar on the right that says so.
 *
 * <p>It is not a {@link Widget} because it does not draw the thing being
 * scrolled. The queue draws rows and the timeline draws a grid of tiles, and
 * both need to interleave their own clipping and their own hit-testing with the
 * offset -- so they own a ScrollModel and ask it questions, rather than handing
 * it their content.
 *
 * <p>Movement is eased against the wall clock rather than snapping. The
 * difference is entirely cosmetic and entirely worth it: a timeline that jumps
 * by exactly one row per notch reads as a list of rows, while one that glides
 * reads as a photo album.
 */
@Accessors(fluent = true)
public final class ScrollModel {

    private static final int BAR_WIDTH = 4;
    private static final int MIN_THUMB_HEIGHT = 16;

    private final Chrome chrome;

    @Getter
    private Rect viewport = Rect.EMPTY;

    @Getter
    private int contentHeight;

    private double offset;
    private double target;
    private long lastFrameMillis;

    private boolean draggingThumb;
    private double dragAnchor;

    public ScrollModel(Chrome chrome) {
        this.chrome = chrome;
    }

    // -----------------------------------------------------------------------
    // Geometry
    // -----------------------------------------------------------------------

    /** Called from the screen's layout pass. Keeps the current position where it can. */
    public void viewport(Rect bounds) {
        this.viewport = bounds;
        clampImmediately();
    }

    /**
     * Called whenever the content grows or shrinks -- a month loading in the
     * timeline, an upload finishing in the queue.
     */
    public void contentHeight(int height) {
        this.contentHeight = Math.max(0, height);
        clampImmediately();
    }

    public int maxOffset() {
        return Math.max(0, contentHeight - viewport.height());
    }

    public boolean scrollable() {
        return maxOffset() > 0;
    }

    /** The current position, rounded, for the arithmetic that positions content. */
    public int offset() {
        return (int) Math.round(offset);
    }

    /** Where the view is heading, which is what "scroll another notch" builds on. */
    public double destination() {
        return target;
    }

    public void scrollBy(double delta) {
        scrollTo(target + delta);
    }

    public void scrollTo(double position) {
        target = Math.max(0, Math.min(maxOffset(), position));
    }

    /** Moves without animating -- for jumping to a date, or restoring a saved position. */
    public void jumpTo(double position) {
        scrollTo(position);
        offset = target;
    }

    private void clampImmediately() {
        target = Math.max(0, Math.min(maxOffset(), target));
        offset = Math.max(0, Math.min(maxOffset(), offset));
    }

    // -----------------------------------------------------------------------
    // Animation
    // -----------------------------------------------------------------------

    /**
     * Advances the eased position. Call once per frame before drawing content,
     * so the content and the bar agree on where the view is.
     *
     * <p>Wall clock, not ticks: PhotoSync screens usually pause the game, and a
     * tick-driven animation would sit still.
     */
    public void advance(long nowMillis) {
        long elapsed = lastFrameMillis == 0 ? 16 : Math.max(0, Math.min(100, nowMillis - lastFrameMillis));
        lastFrameMillis = nowMillis;
        double remaining = target - offset;
        if (Math.abs(remaining) < 0.5) {
            offset = target;
            return;
        }
        offset += remaining * (1 - Math.exp(-elapsed / 45.0));
    }

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

    /**
     * @param step how far one notch travels -- a row for the queue, a tile row
     *             for the timeline
     * @return whether the wheel was over this viewport and did something
     */
    public boolean mouseScrolled(double mouseX, double mouseY, double amount, int step) {
        if (!scrollable() || !viewport.contains(mouseX, mouseY)) {
            return false;
        }
        scrollBy(-amount * step);
        return true;
    }

    public boolean mouseClicked(double mouseX, double mouseY, int button) {
        if (button != 0 || !scrollable() || !trackBounds().contains(mouseX, mouseY)) {
            return false;
        }
        Rect thumb = thumbBounds();
        if (thumb.contains(mouseX, mouseY)) {
            dragAnchor = mouseY - thumb.y();
        } else {
            // Clicking the empty track centres the thumb on the cursor, which is
            // what a long list needs; paging by a screen would take forever.
            dragAnchor = thumb.height() / 2.0;
            jumpToThumbTop(mouseY - dragAnchor);
        }
        draggingThumb = true;
        return true;
    }

    public boolean mouseDragged(double mouseX, double mouseY, int button) {
        if (!draggingThumb) {
            return false;
        }
        jumpToThumbTop(mouseY - dragAnchor);
        return true;
    }

    public boolean mouseReleased() {
        boolean was = draggingThumb;
        draggingThumb = false;
        return was;
    }

    /** Dragging the bar is a direct manipulation, so it does not ease behind the cursor. */
    private void jumpToThumbTop(double thumbTop) {
        Rect track = trackBounds();
        double travel = track.height() - thumbBounds().height();
        double ratio = travel <= 0 ? 0 : (thumbTop - track.y()) / travel;
        jumpTo(Math.max(0, Math.min(1, ratio)) * maxOffset());
    }

    // -----------------------------------------------------------------------
    // The bar
    // -----------------------------------------------------------------------

    /** The full-height strip the thumb runs in, at the right edge of the viewport. */
    public Rect trackBounds() {
        return new Rect(viewport.right() - BAR_WIDTH, viewport.y(), BAR_WIDTH, viewport.height());
    }

    public Rect thumbBounds() {
        Rect track = trackBounds();
        if (!scrollable()) {
            return track;
        }
        int height = Math.max(MIN_THUMB_HEIGHT,
                (int) ((long) track.height() * viewport.height() / contentHeight));
        int travel = track.height() - height;
        int y = track.y() + (int) Math.round(travel * (offset / maxOffset()));
        return new Rect(track.x(), y, track.width(), height);
    }

    /**
     * The width the content should leave clear on the right. Zero when the view
     * is not scrollable, so a short list uses the full width rather than
     * reserving space for a bar that is not there.
     */
    public int gutter() {
        return scrollable() ? BAR_WIDTH + 2 : 0;
    }

    public void render(RenderBridge render, int mouseX, int mouseY) {
        if (!scrollable()) {
            return;
        }
        Theme theme = chrome.theme();
        Rect track = trackBounds();
        Rect thumb = thumbBounds();
        render.fill(track.x(), track.y(), track.width(), track.height(), theme.scrollTrack());
        boolean hot = draggingThumb || thumb.contains(mouseX, mouseY);
        render.fill(thumb.x(), thumb.y(), thumb.width(), thumb.height(),
                hot ? theme.scrollThumbHover() : theme.scrollThumb());
    }
}
