// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ Copyright (c) 2017, the Perspective Authors.                              ┃
// ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃
// ┃ This file is part of the Perspective library, distributed under the terms ┃
// ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

import type { PlotLayout } from "../layout/plot-layout";

export interface ZoomState {
    scaleX: number;
    scaleY: number;

    // Translate as fraction of base domain range (0 = centered, ±0.5 = edge)
    normTranslateX: number;
    normTranslateY: number;
}

/**
 * Runtime config for `ZoomController`. Not part of `ZoomState` — wired
 * by the owning chart at construction, not serialized with zoom level.
 *
 * `lockAxis` pins one axis at `scale=1, translate=0` and suppresses
 * wheel / pan updates on that axis, leaving the other axis fully
 * zoomable. Categorical axes are the typical candidates.
 *
 * `lockAspect` keeps `dataPerPixel` equal on both axes by padding the
 * narrower axis of the rendered domain to match the plot rect's aspect
 * ratio. Required by map plugins (Mercator preserves local angle, so
 * map glyphs distort under independent X/Y zoom), and useful for any
 * cartesian view where stretching points along one axis would lie
 * about the data.
 */
export interface ZoomConfig {
    lockAxis?: "x" | "y" | null;
    lockAspect?: boolean;
}

export const MAX_ZOOM = 100_000;
export const MIN_ZOOM = 1;

/**
 * Pad the narrower axis of `domain` so its aspect ratio matches
 * `plotRect`. Preserves the center of each axis and the longer axis's
 * extent — only the shorter axis grows. Returns the input unmodified
 * if either dimension is non-positive.
 *
 * Used by `lockAspect` mode to keep `dataPerPixel` equal on both axes,
 * which is what map plugins (Mercator) and any "square pixel" view
 * require.
 */
function applyAspectLock(
    domain: { xMin: number; xMax: number; yMin: number; yMax: number },
    plotRect: { width: number; height: number },
): { xMin: number; xMax: number; yMin: number; yMax: number } {
    if (plotRect.width <= 0 || plotRect.height <= 0) {
        return domain;
    }

    const xRange = domain.xMax - domain.xMin;
    const yRange = domain.yMax - domain.yMin;
    if (xRange <= 0 || yRange <= 0) {
        return domain;
    }

    const plotAspect = plotRect.width / plotRect.height;
    const dataAspect = xRange / yRange;

    if (dataAspect < plotAspect) {
        const cx = (domain.xMin + domain.xMax) / 2;
        const newX = (yRange * plotAspect) / 2;
        return {
            xMin: cx - newX,
            xMax: cx + newX,
            yMin: domain.yMin,
            yMax: domain.yMax,
        };
    } else {
        const cy = (domain.yMin + domain.yMax) / 2;
        const newY = xRange / plotAspect / 2;
        return {
            xMin: domain.xMin,
            xMax: domain.xMax,
            yMin: cy - newY,
            yMax: cy + newY,
        };
    }
}

export class ZoomController {
    private _scaleX = 1;
    private _scaleY = 1;

    // Normalized translate: fraction of base domain range
    private _normTX = 0;
    private _normTY = 0;

    private _baseXMin = 0;
    private _baseXMax = 1;
    private _baseYMin = 0;
    private _baseYMax = 1;

    private _lockAxis: "x" | "y" | null = null;
    private _lockAspect = false;

    // Opt-in "pin" flags. When set, `setBaseDomain` preserves the
    // axis's *absolute* visible center across a base swap — the
    // pre-existing rebase math. Default-cleared: data updates "follow"
    // (preserve normalized translate, so the visible window tracks the
    // data fractionally). Wire to a paused-frame review feature when
    // one exists; no current caller flips these.
    private _xPinned = false;
    private _yPinned = false;

    private _element: HTMLElement | null = null;
    private _layout: PlotLayout | null = null;
    private _onUpdate: (() => void) | null = null;

    private _pointerDown = false;
    private _lastPointerX = 0;
    private _lastPointerY = 0;

    private _onWheel: ((e: WheelEvent) => void) | null = null;
    private _onPointerDown: ((e: PointerEvent) => void) | null = null;
    private _onPointerMove: ((e: PointerEvent) => void) | null = null;
    private _onPointerUp: ((e: PointerEvent) => void) | null = null;

    // Per-controller mutators used by `applyWheel` / `applyPan`
    // (zoom-router.ts) for the facet-aware zoom path.
    get lockedAxis(): "x" | "y" | null {
        return this._lockAxis;
    }
    get scaleX(): number {
        return this._scaleX;
    }
    get scaleY(): number {
        return this._scaleY;
    }
    set scaleX(v: number) {
        this._scaleX = v;
    }
    set scaleY(v: number) {
        this._scaleY = v;
    }
    get normTranslateX(): number {
        return this._normTX;
    }
    get normTranslateY(): number {
        return this._normTY;
    }
    set normTranslateX(v: number) {
        this._normTX = v;
    }
    set normTranslateY(v: number) {
        this._normTY = v;
    }
    get baseXRange(): number {
        return this._baseXMax - this._baseXMin;
    }
    get baseYRange(): number {
        return this._baseYMax - this._baseYMin;
    }

    /**
     * Update the base (full-data) domain. Each axis is handled
     * independently:
     *
     *  - If the axis is at default (no user pan or zoom on this axis),
     *    just swap the new base in — no further math needed.
     *  - If the axis has been explicitly *pinned* (`pinAxis("x" | "y")`),
     *    preserve its *absolute* visible center across the swap:
     *    re-solve `_normT` so the data-space center is unchanged.
     *    This is paused-frame-review semantics — the user has marked a
     *    region of interest and wants it to stay put even as new data
     *    flows in. No current caller pins, but the API is here so the
     *    default rule below doesn't bake the choice in.
     *  - Otherwise (the default — "follow"): keep `_normT` as-is and
     *    just swap the new base. The visible window's *fractional*
     *    position is preserved, so sliding windows slide with the data
     *    and extending windows grow proportionally with the data.
     *
     * Per-axis handling matters because `_scaleX/_normTX` and
     * `_scaleY/_normTY` are independent. A user who panned X to scroll
     * through time should not force Y onto the rebase path — Y data
     * updates should still flow through cleanly.
     */
    setBaseDomain(
        xMin: number,
        xMax: number,
        yMin: number,
        yMax: number,
    ): void {
        const newRangeX = xMax - xMin;
        if (this.isXDefault() || !this._xPinned) {
            this._baseXMin = xMin;
            this._baseXMax = xMax;
        } else {
            const oldRangeX = this._baseXMax - this._baseXMin;
            const oldCx =
                (this._baseXMin + this._baseXMax) / 2 +
                this._normTX * oldRangeX;
            this._baseXMin = xMin;
            this._baseXMax = xMax;
            this._normTX =
                newRangeX > 0 ? (oldCx - (xMin + xMax) / 2) / newRangeX : 0;
        }

        const newRangeY = yMax - yMin;
        if (this.isYDefault() || !this._yPinned) {
            this._baseYMin = yMin;
            this._baseYMax = yMax;
        } else {
            const oldRangeY = this._baseYMax - this._baseYMin;
            const oldCy =
                (this._baseYMin + this._baseYMax) / 2 +
                this._normTY * oldRangeY;
            this._baseYMin = yMin;
            this._baseYMax = yMax;
            this._normTY =
                newRangeY > 0 ? (oldCy - (yMin + yMax) / 2) / newRangeY : 0;
        }
    }

    /**
     * Mark an axis as "pinned" so subsequent `setBaseDomain` calls
     * preserve its *absolute* visible center (paused-frame-review
     * semantics). Default-cleared on construction; both axes follow
     * data growth fractionally until explicitly pinned.
     */
    pinAxis(axis: "x" | "y"): void {
        if (axis === "x") {
            this._xPinned = true;
        } else {
            this._yPinned = true;
        }
    }

    unpinAxis(axis: "x" | "y"): void {
        if (axis === "x") {
            this._xPinned = false;
        } else {
            this._yPinned = false;
        }
    }

    /**
     * Apply config. Called once by the chart during `setZoomController`.
     * Locking an axis snaps its `scale`/`translate` to identity so any
     * pre-existing state on that axis is cleared; subsequent wheel /
     * pan events leave the locked axis alone.
     */
    configure(config: ZoomConfig): void {
        this._lockAxis = config.lockAxis ?? null;
        this._lockAspect = config.lockAspect ?? false;
        if (this._lockAxis === "x") {
            this._scaleX = 1;
            this._normTX = 0;
        } else if (this._lockAxis === "y") {
            this._scaleY = 1;
            this._normTY = 0;
        }
    }

    isXDefault(): boolean {
        return this._scaleX === 1 && this._normTX === 0;
    }

    isYDefault(): boolean {
        return this._scaleY === 1 && this._normTY === 0;
    }

    isDefault(): boolean {
        return this.isXDefault() && this.isYDefault();
    }

    getVisibleDomain(): {
        xMin: number;
        xMax: number;
        yMin: number;
        yMax: number;
    } {
        const bxRange = this._baseXMax - this._baseXMin;
        const byRange = this._baseYMax - this._baseYMin;
        const vxRange = bxRange / this._scaleX;
        const vyRange = byRange / this._scaleY;

        // Center = base midpoint + normalized translate * base range
        const cx =
            (this._baseXMin + this._baseXMax) / 2 + this._normTX * bxRange;
        const cy =
            (this._baseYMin + this._baseYMax) / 2 + this._normTY * byRange;

        const domain = {
            xMin: cx - vxRange / 2,
            xMax: cx + vxRange / 2,
            yMin: cy - vyRange / 2,
            yMax: cy + vyRange / 2,
        };

        if (this._lockAspect && this._layout) {
            return applyAspectLock(domain, this._layout.plotRect);
        }

        return domain;
    }

    attach(
        element: HTMLElement,
        layout: PlotLayout,
        onUpdate: () => void,
    ): void {
        this.detach();
        this._element = element;
        this._layout = layout;
        this._onUpdate = onUpdate;

        this._onWheel = (e: WheelEvent) => {
            e.preventDefault();
            const rect = element.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const mouseY = e.clientY - rect.top;
            const plot = this._layout!.plotRect;

            if (
                mouseX < plot.x ||
                mouseX > plot.x + plot.width ||
                mouseY < plot.y ||
                mouseY > plot.y + plot.height
            ) {
                return;
            }

            // Cursor position as a fraction of the plot rect — the
            // anchor point that should stay visually fixed across the
            // zoom mutation below. `fracY` is 0 at top, 1 at bottom
            // (screen coords); Y data axis is inverted, hence the
            // `0.5 - fracY` form below.
            const fracX = (mouseX - plot.x) / plot.width;
            const fracY = (mouseY - plot.y) / plot.height;
            const oldScaleX = this._scaleX;
            const oldScaleY = this._scaleY;

            // Zoom factor — skip the locked axis so its scale stays
            // pinned at 1.
            const factor = Math.pow(1.1, -e.deltaY / 100);
            if (this._lockAxis !== "x") {
                this._scaleX = Math.max(
                    MIN_ZOOM,
                    Math.min(MAX_ZOOM, this._scaleX * factor),
                );
            }

            if (this._lockAxis !== "y") {
                this._scaleY = Math.max(
                    MIN_ZOOM,
                    Math.min(MAX_ZOOM, this._scaleY * factor),
                );
            }

            // Cursor-anchored zoom: keep the data point under the
            // cursor visually fixed. Derivation, for X:
            //   dataX(scale) = mid + normTX*baseRange
            //                + (fracX - 0.5) * baseRange/scale
            // After mutating scale (normTX unchanged), the cursor data
            // coord shifts. Re-anchoring is `normTX +=
            // (oldDataX - newDataX) / baseRange`; the `baseRange`
            // terms cancel to:
            //   normTXDelta = (fracX - 0.5) * (1/oldScale - 1/newScale)
            // Base-independent — a concurrent `setBaseDomain` mid-
            // handler can't corrupt the anchor math. Y axis is screen-
            // inverted, hence `(0.5 - fracY)`.
            if (this._lockAxis !== "x" && oldScaleX !== this._scaleX) {
                this._normTX +=
                    (fracX - 0.5) * (1 / oldScaleX - 1 / this._scaleX);
            }

            if (this._lockAxis !== "y" && oldScaleY !== this._scaleY) {
                this._normTY +=
                    (0.5 - fracY) * (1 / oldScaleY - 1 / this._scaleY);
            }

            this._onUpdate!();
        };

        this._onPointerDown = (e: PointerEvent) => {
            const rect = element.getBoundingClientRect();
            const mouseX = e.clientX - rect.left;
            const mouseY = e.clientY - rect.top;
            const plot = this._layout!.plotRect;

            if (
                mouseX >= plot.x &&
                mouseX <= plot.x + plot.width &&
                mouseY >= plot.y &&
                mouseY <= plot.y + plot.height
            ) {
                this._pointerDown = true;
                this._lastPointerX = e.clientX;
                this._lastPointerY = e.clientY;
                element.setPointerCapture(e.pointerId);
            }
        };

        this._onPointerMove = (e: PointerEvent) => {
            if (!this._pointerDown) {
                return;
            }

            const dx = e.clientX - this._lastPointerX;
            const dy = e.clientY - this._lastPointerY;
            this._lastPointerX = e.clientX;
            this._lastPointerY = e.clientY;

            // Pan as a fraction is `dx / (plotWidth * scaleX)` —
            // independent of the current base range. The chained form
            // (`pixels → data delta → fraction-of-base`) cancels the
            // `baseRange` terms algebraically; computing the cancelled
            // form directly means a concurrent `setBaseDomain` swap
            // mid-gesture cannot corrupt the pan math.
            const plot = this._layout!.plotRect;
            if (this._lockAxis !== "x" && plot.width > 0) {
                this._normTX -= dx / (plot.width * this._scaleX);
            }

            if (this._lockAxis !== "y" && plot.height > 0) {
                this._normTY += dy / (plot.height * this._scaleY);
            }

            this._onUpdate!();
        };

        this._onPointerUp = () => {
            this._pointerDown = false;
        };

        element.addEventListener("wheel", this._onWheel, { passive: false });
        element.addEventListener("pointerdown", this._onPointerDown);
        element.addEventListener("pointermove", this._onPointerMove);
        element.addEventListener("pointerup", this._onPointerUp);
    }

    updateLayout(layout: PlotLayout): void {
        this._layout = layout;
    }

    detach(): void {
        if (this._element) {
            if (this._onWheel) {
                this._element.removeEventListener("wheel", this._onWheel);
            }

            if (this._onPointerDown) {
                this._element.removeEventListener(
                    "pointerdown",
                    this._onPointerDown,
                );
            }

            if (this._onPointerMove) {
                this._element.removeEventListener(
                    "pointermove",
                    this._onPointerMove,
                );
            }

            if (this._onPointerUp) {
                this._element.removeEventListener(
                    "pointerup",
                    this._onPointerUp,
                );
            }
        }

        this._element = null;
        this._onUpdate = null;
    }

    reset(): void {
        this._scaleX = 1;
        this._scaleY = 1;
        this._normTX = 0;
        this._normTY = 0;
    }

    serialize(): ZoomState {
        return {
            scaleX: this._scaleX,
            scaleY: this._scaleY,
            normTranslateX: this._normTX,
            normTranslateY: this._normTY,
        };
    }

    restore(state: ZoomState): void {
        this._scaleX = state.scaleX;
        this._scaleY = state.scaleY;
        this._normTX = state.normTranslateX;
        this._normTY = state.normTranslateY;
    }
}
