// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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). ┃
// ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛

export interface PlotMargins {
    top: number;
    right: number;
    bottom: number;
    left: number;
}

export interface PlotRect {
    x: number;
    y: number;
    width: number;
    height: number;
}

export interface PlotLayoutOptions {
    hasXLabel: boolean;
    hasYLabel: boolean;
    hasLegend: boolean;

    /**
     * Additional CSS-pixel height reserved at the bottom of the plot for a
     * hierarchical / rotated categorical X axis. Overrides the default 24px
     * tick band. The axis-label allowance from `hasXLabel` is preserved.
     */
    bottomExtra?: number;

    /**
     * Total CSS-pixel width reserved at the left of the plot for a
     * hierarchical categorical Y axis. Overrides the default `55 +
     * hasYLabel*16` left gutter. The axis-label allowance from `hasYLabel`
     * is preserved.
     */
    leftExtra?: number;

    /**
     * Total CSS-pixel width reserved at the right of the plot. Overrides
     * the default (`80` when `hasLegend`, else `16`). Faceted cells
     * without axes (treemap / sunburst in grid mode) pass `0` to make
     * adjacent cell plot rects flush; axis-bearing charts leave it
     * unset to keep the default breathing-room margin.
     */
    rightExtra?: number;

    /**
     * Absolute canvas-coordinate offset for this layout's plot origin.
     * When set, `cssWidth` / `cssHeight` describe the *outer* canvas, and
     * `originX` / `originY` name the top-left corner of the cell this
     * layout represents. The cell's own width/height come from
     * `cellWidth` / `cellHeight`. `margins` are computed relative to the
     * cell then shifted into canvas-absolute space so projection
     * matrices, scissor, and `dataToPixel` all operate in full-canvas
     * coordinates without branching per-facet.
     *
     * When any of these fields is unset, the layout is single-plot: the
     * cell occupies the whole canvas and `originX` / `originY` default
     * to 0.
     */
    originX?: number;
    originY?: number;
    cellWidth?: number;
    cellHeight?: number;
}

/**
 * Coordinates margins and coordinate transforms between WebGL and Canvas2D.
 * All measurements are in CSS pixels (not physical/DPR-scaled pixels).
 */
export class PlotLayout {
    readonly margins: PlotMargins;
    readonly plotRect: PlotRect;
    readonly cssWidth: number;
    readonly cssHeight: number;

    // Padded domain set by buildProjectionMatrix, used by dataToPixel
    // and pixelToData for tooltip hit-testing.
    paddedXMin = 0;
    paddedXMax = 1;
    paddedYMin = 0;
    paddedYMax = 1;

    constructor(
        cssWidth: number,
        cssHeight: number,
        options: PlotLayoutOptions,
    ) {
        this.cssWidth = cssWidth;
        this.cssHeight = cssHeight;

        const baseLeft = options.leftExtra ?? 55;
        const left = baseLeft + (options.hasYLabel ? 16 : 0);
        const baseBottom = options.bottomExtra ?? 24;
        const bottom = baseBottom + (options.hasXLabel ? 18 : 0);
        const top = 0;
        const right = options.rightExtra ?? (options.hasLegend ? 80 : 16);

        // Facet cells: the sub-plot lives at `(originX, originY)` within a
        // larger canvas of size `(cssWidth, cssHeight)`. Its own bounds are
        // `cellWidth × cellHeight`. The gutters above are then interpreted
        // inside that cell, and `margins` / `plotRect` are shifted into
        // canvas-absolute coordinates. Single-plot layouts leave these
        // unset, in which case `originX / originY = 0` and the cell
        // occupies the whole canvas — identical to pre-facet semantics.
        const originX = options.originX ?? 0;
        const originY = options.originY ?? 0;
        const cellW = options.cellWidth ?? cssWidth;
        const cellH = options.cellHeight ?? cssHeight;

        const marginLeftAbs = originX + left;
        const marginTopAbs = originY + top;
        const plotW = Math.max(1, cellW - left - right);
        const plotH = Math.max(1, cellH - top - bottom);
        const marginRightAbs = cssWidth - (marginLeftAbs + plotW);
        const marginBottomAbs = cssHeight - (marginTopAbs + plotH);

        this.margins = {
            top: marginTopAbs,
            right: marginRightAbs,
            bottom: marginBottomAbs,
            left: marginLeftAbs,
        };

        this.plotRect = {
            x: marginLeftAbs,
            y: marginTopAbs,
            width: plotW,
            height: plotH,
        };
    }

    /**
     * Build an orthographic projection matrix that maps data coordinates
     * [xMin..xMax, yMin..yMax] to the plot area sub-region of clip space [-1, 1].
     *
     * The matrix bakes margin offsets into the transform so that gl.viewport
     * remains full-canvas and no scissor/sub-viewport is needed.
     *
     * `clamp`, when set, names the axis that carries the *value* (as
     * opposed to categorical / positional) data. Today it only affects
     * `requireZero`; both axes always receive symmetric `padRatio` padding.
     *
     * `requireZero`, when true, guarantees that the unpadded value `0`
     * falls inside the clamped axis's final domain. For all-positive
     * data the axis minimum is pinned at `0` (the baseline sits on the
     * axis line); for all-negative data the maximum is pinned at `0`;
     * for data that already straddles zero, nothing changes. Pairs with
     * `clamp`, and is a no-op when `clamp` is unset.
     *
     * `padRatio` controls the symmetric cosmetic pad on both axes
     * (default 2%). Charts that want plot edges flush with the axes
     * (e.g. heatmap, whose cell rects already span the exact domain)
     * pass `0`.
     */
    buildProjectionMatrix(
        xMin: number,
        xMax: number,
        yMin: number,
        yMax: number,
        clamp?: "x" | "y",
        requireZero?: boolean,
        padRatio: number = 0.02,
        xOrigin: number = 0,
        yOrigin: number = 0,
    ): Float32Array {
        // Symmetric cosmetic padding on both axes (default 2%).
        let xRange = xMax - xMin;
        let yRange = yMax - yMin;
        if (xRange === 0) {
            xRange = 1;
        }

        if (yRange === 0) {
            yRange = 1;
        }

        const xPad = xRange * padRatio;
        const yPad = yRange * padRatio;

        // Evaluate the zero-snap condition against the *pre-pad*
        // values so that an exact-zero boundary (e.g. bar pipelines
        // that snap `valMin` to 0) still qualifies — otherwise the
        // padding step would tip the boundary slightly negative and
        // the snap branch below would miss. Inclusive comparison is
        // deliberate.
        const snapYMin = requireZero && clamp === "y" && yMin >= 0;
        const snapYMax = requireZero && clamp === "y" && yMax <= 0;
        const snapXMin = requireZero && clamp === "x" && xMin >= 0;
        const snapXMax = requireZero && clamp === "x" && xMax <= 0;

        xMin -= xPad;
        xMax += xPad;
        yMin -= yPad;
        yMax += yPad;

        // Pin the snapped boundary to exactly zero and give the
        // opposite boundary a second pad for visual headroom above the
        // tallest bar. No-op when data straddles zero (neither flag
        // set) so no boundary collapses onto an in-range value.
        if (snapYMin) {
            yMin = 0;
            yMax += yPad;
        } else if (snapYMax) {
            yMax = 0;
            yMin -= yPad;
        }

        if (snapXMin) {
            xMin = 0;
            xMax += xPad;
        } else if (snapXMax) {
            xMax = 0;
            xMin -= xPad;
        }

        // Store padded domain for dataToPixel
        this.paddedXMin = xMin;
        this.paddedXMax = xMax;
        this.paddedYMin = yMin;
        this.paddedYMax = yMax;

        // Clip-space bounds for the plot area
        const clipLeft = (2 * this.margins.left) / this.cssWidth - 1;
        const clipRight = 1 - (2 * this.margins.right) / this.cssWidth;
        const clipBottom = (2 * this.margins.bottom) / this.cssHeight - 1;
        const clipTop = 1 - (2 * this.margins.top) / this.cssHeight;

        // Scale and translate: rebased data [(min-origin), (max-origin)]
        // → clip [clipMin, clipMax]. Callers that ship rebased values to
        // the GPU pass the origin they used; for ordinary numeric data
        // both origins default to 0 and the math collapses to the
        // legacy `tx = clipLeft - sx*xMin` form. With a datetime axis
        // the origin lifts ~1.7e12 out of `tx` before the f32
        // narrowing in the matrix below, keeping shader cancellation
        // precision-safe down to sub-millisecond granularity.
        const sx = (clipRight - clipLeft) / (xMax - xMin);
        const sy = (clipTop - clipBottom) / (yMax - yMin);
        const tx = clipLeft - sx * (xMin - xOrigin);
        const ty = clipBottom - sy * (yMin - yOrigin);

        // Column-major 4x4 matrix
        // prettier-ignore
        return new Float32Array([
            sx,  0,   0, 0,
            0,   sy,  0, 0,
            0,   0,  -1, 0,
            tx,  ty,  0, 1,
        ]);
    }

    /**
     * Convert data coordinates to CSS pixel coordinates on the overlay canvas.
     * Uses the padded domain from the last `buildProjectionMatrix` call so
     * that pixel positions align exactly with the WebGL projection.
     */
    dataToPixel(dataX: number, dataY: number): { px: number; py: number } {
        const { x, y, width, height } = this.plotRect;
        const tx =
            (dataX - this.paddedXMin) / (this.paddedXMax - this.paddedXMin);
        const ty =
            (dataY - this.paddedYMin) / (this.paddedYMax - this.paddedYMin);
        return {
            px: x + tx * width,
            py: y + (1 - ty) * height, // Y is flipped (CSS Y goes down)
        };
    }
}
