// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { CartesianChart } from "./cartesian";
import { renderCartesianChromeOverlay } from "./cartesian-render";

const TOOLTIP_RADIUS_PX = 24;

/**
 * Lazily rebuild the spatial hit-test index from the current CPU-side
 * point buffers. Walks every series slot so ranges with gaps (unused
 * tails) are skipped naturally.
 */
function ensureCartesianSpatialGrid(chart: CartesianChart): void {
    if (!chart._hitTest.isDirty || !chart._xData || !chart._yData) {
        return;
    }

    const xData = chart._xData;
    const yData = chart._yData;
    const numSeries = Math.max(1, chart._splitGroups.length);
    const cap = chart._seriesCapacity;

    // `_xData`/`_yData` hold rebased values (`absolute - origin`) so the
    // f32 GPU pipeline keeps sub-millisecond precision for datetime
    // axes; the spatial-grid bounds and queries below must live in the
    // same rebased space.
    const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
    const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
    chart._hitTest.rebuild(
        {
            xMin: chart._xMin - xOrigin,
            xMax: chart._xMax - xOrigin,
            yMin: chart._yMin - yOrigin,
            yMax: chart._yMax - yOrigin,
        },
        chart._dataCount,
        (insert) => {
            for (let s = 0; s < numSeries; s++) {
                const count = chart._seriesUploadedCounts[s] ?? 0;
                const base = s * cap;
                for (let j = 0; j < count; j++) {
                    insert(base + j, xData[base + j], yData[base + j]);
                }
            }
        },
    );
}

/**
 * Update {@link CartesianChart._hoveredIndex} for the given mouse
 * position. Triggers a chrome re-render if the hovered index changes.
 *
 * In faceted mode, the hit test first resolves which facet the mouse is
 * over, then restricts the search to that facet's series slice. This
 * makes hover local to a facet; coordinated ghost indicators in other
 * facets are painted by the chrome overlay.
 */
export function handleCartesianHover(
    chart: CartesianChart,
    mx: number,
    my: number,
): void {
    if (!chart._xData || !chart._yData) {
        return;
    }

    // Resolve the facet (and its layout) under the cursor. Non-facet
    // charts have `_facetGrid = null` and fall back to the cached
    // `_lastLayout`; the hover then scans every series.
    const { layout, facetIdx } = resolveHoverTarget(chart, mx, my);
    if (!layout) {
        clearHover(chart);
        return;
    }

    const plot = layout.plotRect;
    if (
        mx < plot.x ||
        mx > plot.x + plot.width ||
        my < plot.y ||
        my > plot.y + plot.height
    ) {
        clearHover(chart);
        return;
    }

    const xMin = layout.paddedXMin;
    const xMax = layout.paddedXMax;
    const yMin = layout.paddedYMin;
    const yMax = layout.paddedYMax;
    const dataX = xMin + ((mx - plot.x) / plot.width) * (xMax - xMin);
    const dataY = yMax - ((my - plot.y) / plot.height) * (yMax - yMin);
    const pxPerDataX = plot.width / (xMax - xMin);
    const pxPerDataY = plot.height / (yMax - yMin);

    // `_xData`/`_yData` are rebased (see `processCartesianChunk`), so
    // pass rebased mouse coords into the hit-test routines below; range
    // ratios (`pxPerData*`) are translation-invariant and stay as-is.
    const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
    const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
    const dataXRebased = dataX - xOrigin;
    const dataYRebased = dataY - yOrigin;

    const bestIdx =
        facetIdx < 0
            ? hoverAllSeries(
                  chart,
                  dataXRebased,
                  dataYRebased,
                  pxPerDataX,
                  pxPerDataY,
              )
            : hoverOneSeries(
                  chart,
                  facetIdx,
                  dataXRebased,
                  dataYRebased,
                  pxPerDataX,
                  pxPerDataY,
              );

    if (bestIdx !== chart._hoveredIndex || facetIdx !== chart._hoveredFacet) {
        chart._hoveredIndex = bestIdx;
        chart._hoveredFacet = facetIdx;
        if (bestIdx >= 0) {
            // Fire the lazy tooltip build; the controller drops stale
            // resolves so rapid mouse motion can't paint out-of-date
            // text. Crosshair / highlight ring are painted immediately
            // from geometry so the hover feels instant; the tooltip
            // box fills in once the row arrives (no "loading…" flicker).
            const serial = chart._lazyTooltip.beginHover(bestIdx);
            chart.glyph.buildTooltipLines(chart, bestIdx).then((lines) => {
                if (chart._lazyTooltip.commitHover(serial, lines)) {
                    renderCartesianChromeOverlay(chart);
                }
            });
        } else {
            chart._lazyTooltip.clearHover();
        }

        renderCartesianChromeOverlay(chart);
    }
}

function clearHover(chart: CartesianChart): void {
    if (chart._hoveredIndex !== -1 || chart._hoveredFacet !== -1) {
        chart._hoveredIndex = -1;
        chart._hoveredFacet = -1;
        renderCartesianChromeOverlay(chart);
    }
}

/**
 * Return `(layout, facetIdx)` for the sub-plot under `(mx, my)`.
 * `facetIdx` is `-1` in single-plot mode; the caller then scans every
 * series (legacy behaviour). In faceted mode, `-1` also signals "mouse
 * is in the grid frame but not inside any plot rect" — the caller
 * clears hover in that case.
 */
function resolveHoverTarget(
    chart: CartesianChart,
    mx: number,
    my: number,
): {
    layout: import("../../layout/plot-layout").PlotLayout | null;
    facetIdx: number;
} {
    if (chart._facetGrid) {
        const cells = chart._facetGrid.cells;
        for (let i = 0; i < cells.length; i++) {
            const plot = cells[i].layout.plotRect;
            if (
                mx >= plot.x &&
                mx <= plot.x + plot.width &&
                my >= plot.y &&
                my <= plot.y + plot.height
            ) {
                return { layout: cells[i].layout, facetIdx: i };
            }
        }

        return { layout: null, facetIdx: -1 };
    }

    return { layout: chart._lastLayout, facetIdx: -1 };
}

function hoverAllSeries(
    chart: CartesianChart,
    dataX: number,
    dataY: number,
    pxPerDataX: number,
    pxPerDataY: number,
): number {
    ensureCartesianSpatialGrid(chart);
    let bestIdx = chart._hitTest.query(
        dataX,
        dataY,
        TOOLTIP_RADIUS_PX,
        pxPerDataX,
        pxPerDataY,
        chart._xData,
        chart._yData,
    );
    if (bestIdx >= 0) {
        return bestIdx;
    }

    // Brute-force fallback over every valid slot.
    let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX;
    const numSeries = Math.max(1, chart._splitGroups.length);
    const cap = chart._seriesCapacity;
    const xData = chart._xData!;
    const yData = chart._yData!;
    for (let s = 0; s < numSeries; s++) {
        const count = chart._seriesUploadedCounts[s] ?? 0;
        const base = s * cap;
        for (let j = 0; j < count; j++) {
            const idx = base + j;
            const dx = (xData[idx] - dataX) * pxPerDataX;
            const dy = (yData[idx] - dataY) * pxPerDataY;
            const distSq = dx * dx + dy * dy;
            if (distSq < bestDistSq) {
                bestDistSq = distSq;
                bestIdx = idx;
            }
        }
    }

    return bestIdx;
}

/**
 * Hit-test a single series' slot range. Faceted mode scopes hover to
 * the series that owns the facet under the cursor; the spatial grid
 * spans all series so we do a brute-force scan over just that series'
 * slice — cheap even for dense datasets because only `count[s]` slots
 * are read.
 */
function hoverOneSeries(
    chart: CartesianChart,
    seriesIdx: number,
    dataX: number,
    dataY: number,
    pxPerDataX: number,
    pxPerDataY: number,
): number {
    const count = chart._seriesUploadedCounts[seriesIdx] ?? 0;
    if (count === 0) {
        return -1;
    }

    const cap = chart._seriesCapacity;
    const base = seriesIdx * cap;
    const xData = chart._xData!;
    const yData = chart._yData!;

    let bestDistSq = TOOLTIP_RADIUS_PX * TOOLTIP_RADIUS_PX;
    let bestIdx = -1;
    for (let j = 0; j < count; j++) {
        const idx = base + j;
        const dx = (xData[idx] - dataX) * pxPerDataX;
        const dy = (yData[idx] - dataY) * pxPerDataY;
        const distSq = dx * dx + dy * dy;
        if (distSq < bestDistSq) {
            bestDistSq = distSq;
            bestIdx = idx;
        }
    }

    return bestIdx;
}

/**
 * Show a sticky (pinned) tooltip at the given point, anchored to the
 * GL canvas's parent via the tooltip controller.
 *
 * In faceted mode, resolves the source facet from `pointIdx` and uses
 * that cell's layout so the tooltip anchors to the correct sub-plot.
 */
export function showCartesianPinnedTooltip(
    chart: CartesianChart,
    pointIdx: number,
): void {
    chart._tooltip.dismiss();
    chart._pinnedIndex = pointIdx;
    if (pointIdx < 0 || !chart._xData || !chart._yData) {
        return;
    }

    const layout = layoutForIndex(chart, pointIdx);
    if (!layout) {
        return;
    }

    const xOrigin = isNaN(chart._xOrigin) ? 0 : chart._xOrigin;
    const yOrigin = isNaN(chart._yOrigin) ? 0 : chart._yOrigin;
    const pos = layout.dataToPixel(
        chart._xData[pointIdx] + xOrigin,
        chart._yData[pointIdx] + yOrigin,
    );

    const serial = chart._lazyTooltip.beginPin();
    chart.glyph.buildTooltipLines(chart, pointIdx).then((lines) => {
        // Abandon the pin if the user moved on (another pin/dismiss
        // between click and resolve) or the underlying view changed.
        if (!chart._lazyTooltip.isPinFresh(serial)) {
            return;
        }

        if (chart._pinnedIndex !== pointIdx) {
            return;
        }

        if (lines.length === 0) {
            return;
        }

        chart._tooltip.pin(lines, pos, layout);
    });

    chart._hoveredIndex = -1;
    chart._hoveredFacet = -1;
    renderCartesianChromeOverlay(chart);
}

function layoutForIndex(
    chart: CartesianChart,
    pointIdx: number,
): import("../../layout/plot-layout").PlotLayout | null {
    if (chart._facetGrid && chart._seriesCapacity > 0) {
        const s = Math.floor(pointIdx / chart._seriesCapacity);
        const cell = chart._facetGrid.cells[s];
        if (cell) {
            return cell.layout;
        }
    }

    return chart._lastLayout;
}

export function dismissCartesianPinnedTooltip(chart: CartesianChart): void {
    chart._tooltip.dismiss();
    chart._pinnedIndex = -1;
}
