// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
// ┃ ██████ ██████ ██████       █      █      █      █      █ █▄  ▀███ █       ┃
// ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█  ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄  ▀█ █ ▀▀▀▀▀ ┃
// ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄   █ ▄▄▄▄▄ ┃
// ┃ █      ██████ █  ▀█▄       █ ██████      █      ███▌▐███ ███████▄ █       ┃
// ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫
// ┃ 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 { Context2D } from "../charts/canvas-types";
import { PlotLayout } from "../layout/plot-layout";
import {
    labelRect,
    rectContained,
    rectsOverlap,
    rotatedLabelsOverlap,
    truncateLabel,
} from "./label-geometry";
import { type GroupRun, runsInRange } from "./categorical-axis-core";
import type { Theme } from "../theme/theme";

export interface CategoricalLevel {
    labels: string[];
    runs: GroupRun[];
    maxLabelChars: number;
}

export interface CategoricalDomain {
    levels: CategoricalLevel[];
    numRows: number;
    levelLabels: string[];
}

interface LevelTickLayout {
    size: number;
    rotation: 0 | 45 | 90;
}

const LEAF_LEVEL_HEIGHT = 25;
const OUTER_LEVEL_HEIGHT = 22;
const TICK_SIZE = 5;
const LABEL_FONT_PX = 11;
const LABEL_LINE_HEIGHT = 14;
const LEAF_LEVEL_WIDTH_MIN = 55;
const OUTER_LEVEL_WIDTH = 60;
const LEAF_LABEL_PADDING = 10;

function categoryIndexToPixelX(layout: PlotLayout, index: number): number {
    return layout.dataToPixel(index, 0).px;
}

function categoryIndexToPixelY(layout: PlotLayout, index: number): number {
    return layout.dataToPixel(0, index).py;
}

function leafLevelLayout(
    numRows: number,
    longestCharCount: number,
    plotWidth: number,
): LevelTickLayout {
    const budget = Math.max(0, plotWidth - 100);
    if (numRows * 16 > budget) {
        return { size: longestCharCount * 6.62 + 10, rotation: 90 };
    }

    if (numRows * (longestCharCount * 6 + 10) > budget) {
        return { size: longestCharCount * 4 + 20, rotation: 45 };
    }

    return { size: LEAF_LEVEL_HEIGHT, rotation: 0 };
}

export function measureCategoricalLevels(
    domain: CategoricalDomain,
    plotWidth: number,
): LevelTickLayout[] {
    const L = domain.levels.length;
    const result: LevelTickLayout[] = [];
    for (let l = 0; l < L; l++) {
        const lev = domain.levels[l];
        if (l === L - 1) {
            result.push(
                leafLevelLayout(domain.numRows, lev.maxLabelChars, plotWidth),
            );
        } else {
            result.push({ size: OUTER_LEVEL_HEIGHT, rotation: 0 });
        }
    }

    return result;
}

export function measureCategoricalLevelWidths(
    domain: CategoricalDomain,
): number[] {
    const L = domain.levels.length;
    const widths: number[] = [];
    const charPx = 6.2;
    for (let l = 0; l < L; l++) {
        if (l === L - 1) {
            const longest = domain.levels[l].maxLabelChars;
            widths.push(
                Math.max(
                    LEAF_LEVEL_WIDTH_MIN,
                    longest * charPx + LEAF_LABEL_PADDING,
                ),
            );
        } else {
            widths.push(OUTER_LEVEL_WIDTH);
        }
    }

    return widths;
}

function sumNumeric(arr: number[]): number {
    let t = 0;
    for (const v of arr) {
        t += v;
    }

    return t;
}

export function measureCategoricalAxisHeight(
    domain: CategoricalDomain,
    plotWidth: number,
): number {
    if (domain.numRows === 0 || domain.levels.length === 0) {
        return 24;
    }

    return sumNumeric(
        measureCategoricalLevels(domain, plotWidth).map((l) => l.size),
    );
}

export function measureCategoricalAxisWidth(domain: CategoricalDomain): number {
    if (domain.numRows === 0 || domain.levels.length === 0) {
        return 55;
    }

    return sumNumeric(measureCategoricalLevelWidths(domain));
}

function selectLeafTickIndices(
    visMin: number,
    visMax: number,
    plotWidth: number,
    avgLabelPx: number,
): number[] {
    const count = visMax - visMin + 1;
    if (count <= 0) {
        return [];
    }

    const maxLabels = Math.max(1, Math.floor(plotWidth / avgLabelPx));
    if (count <= maxLabels) {
        const out: number[] = [];
        for (let i = visMin; i <= visMax; i++) {
            out.push(i);
        }

        return out;
    }

    const step = Math.ceil(count / maxLabels);
    const out: number[] = [];
    for (let i = visMin; i <= visMax; i += step) {
        out.push(i);
    }

    return out;
}

function getLeafText(level: CategoricalLevel, row: number): string {
    return level.labels[row] ?? "";
}

/**
 * Visible row window from a (possibly zoomed) padded data range. `flip`
 * accounts for the categorical Y-axis storing the domain inverted so
 * that catIdx=0 renders at the top.
 */
function visibleRowWindow(
    numRows: number,
    a: number,
    b: number,
    flip: boolean,
): [number, number] | null {
    const lo = flip ? Math.min(a, b) : a;
    const hi = flip ? Math.max(a, b) : b;
    const visMin = Math.max(0, Math.ceil(lo));
    const visMax = Math.min(numRows - 1, Math.floor(hi));
    return visMax < visMin ? null : [visMin, visMax];
}

/**
 * Compute clipped main-axis spans for each visible run. Used by both
 * outer-level renderers; the caller supplies the projection from
 * row-index to the relevant pixel coordinate (X or Y) and the plot
 * extent along the main axis.
 */
function clippedRuns(
    runs: GroupRun[],
    pixelOf: (idx: number) => number,
    mainStart: number,
    mainEnd: number,
): Array<{
    run: GroupRun;
    nearEdge: number;
    farEdge: number;
    nearClip: number;
    farClip: number;
}> {
    const out = [];
    for (const run of runs) {
        const nearEdge = pixelOf(run.startIdx - 0.5);
        const farEdge = pixelOf(run.endIdx + 0.5);
        const nearClip = Math.max(mainStart, Math.min(nearEdge, farEdge));
        const farClip = Math.min(mainEnd, Math.max(nearEdge, farEdge));
        if (farClip > nearClip) {
            out.push({ run, nearEdge, farEdge, nearClip, farClip });
        }
    }

    return out;
}

export function renderCategoricalXTicks(
    ctx: Context2D,
    layout: PlotLayout,
    domain: CategoricalDomain,
    theme: Theme,
): void {
    if (domain.numRows === 0 || domain.levels.length === 0) {
        return;
    }

    const { tickColor, labelColor, fontFamily } = theme;
    const { plotRect: plot } = layout;
    const baselineY = plot.y + plot.height;

    ctx.strokeStyle = tickColor;
    ctx.fillStyle = tickColor;
    ctx.lineWidth = 1;
    ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;

    const levelLayouts = measureCategoricalLevels(domain, plot.width);
    const win = visibleRowWindow(
        domain.numRows,
        layout.paddedXMin,
        layout.paddedXMax,
        false,
    );
    if (!win) {
        return;
    }

    const [visMin, visMax] = win;

    const L = domain.levels.length;
    let yCursor = baselineY;
    for (let l = L - 1; l >= 0; l--) {
        const level = domain.levels[l];
        const lay = levelLayouts[l];
        const rowTop = yCursor;
        yCursor += lay.size;

        if (l === L - 1) {
            renderLeafLevel(
                ctx,
                layout,
                level,
                visMin,
                visMax,
                rowTop,
                lay,
                fontFamily,
                tickColor,
            );
        } else {
            renderOuterLevel(
                ctx,
                layout,
                level,
                visMin,
                visMax,
                rowTop,
                fontFamily,
                tickColor,
            );
        }
    }

    const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / ");
    if (axisLabel) {
        ctx.fillStyle = labelColor;
        ctx.font = `13px ${fontFamily}`;
        ctx.textAlign = "center";
        ctx.textBaseline = "bottom";
        ctx.fillText(axisLabel, plot.x + plot.width / 2, layout.cssHeight - 2);
    }
}

function renderLeafLevel(
    ctx: Context2D,
    layout: PlotLayout,
    level: CategoricalLevel,
    visMin: number,
    visMax: number,
    rowTop: number,
    lay: LevelTickLayout,
    fontFamily: string,
    tickColor: string,
): void {
    const { plotRect: plot } = layout;

    const avgCharWidth = 6.2;
    const avgLabelPx = Math.max(
        40,
        Math.min(level.maxLabelChars * avgCharWidth + 8, plot.width / 2),
    );

    const tickRows =
        lay.rotation === 0
            ? selectLeafTickIndices(visMin, visMax, plot.width, avgLabelPx)
            : leafRowsForRotated(visMin, visMax);

    ctx.strokeStyle = tickColor;
    ctx.fillStyle = tickColor;
    ctx.beginPath();
    for (const r of tickRows) {
        const px = categoryIndexToPixelX(layout, r);
        if (px < plot.x - 1 || px > plot.x + plot.width + 1) {
            continue;
        }

        ctx.moveTo(px, rowTop);
        ctx.lineTo(px, rowTop + TICK_SIZE);
    }

    ctx.stroke();

    ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
    const labelY = rowTop + TICK_SIZE + 3;
    const boundsRect = {
        x: plot.x,
        width: plot.width,
        y: rowTop,
        height: 9999,
    };
    const kept: {
        x: number;
        y: number;
        width: number;
        height: number;
    }[] = [];
    for (const r of tickRows) {
        const px = categoryIndexToPixelX(layout, r);
        if (px < plot.x - 1 || px > plot.x + plot.width + 1) {
            continue;
        }

        const text = getLeafText(level, r);
        if (!text) {
            continue;
        }

        const textWidth = ctx.measureText(text).width;
        const rect = labelRect(
            px,
            labelY,
            textWidth,
            LABEL_LINE_HEIGHT,
            lay.rotation,
        );
        if (!rectContained(rect, boundsRect)) {
            continue;
        }

        if (lay.rotation === 0) {
            if (kept.some((r) => rectsOverlap(r, rect))) {
                continue;
            }
        } else {
            if (kept.some((r) => rotatedLabelsOverlap(r, rect))) {
                continue;
            }
        }

        kept.push(rect);

        drawLabel(ctx, text, px, labelY, lay.rotation, "center");
    }
}

function renderOuterLevel(
    ctx: Context2D,
    layout: PlotLayout,
    level: CategoricalLevel,
    visMin: number,
    visMax: number,
    rowTop: number,
    fontFamily: string,
    tickColor: string,
): void {
    const { plotRect: plot } = layout;
    const runs = runsInRange(level.runs, visMin, visMax);
    if (runs.length === 0) {
        return;
    }

    const clipped = clippedRuns(
        runs,
        (idx) => categoryIndexToPixelX(layout, idx),
        plot.x,
        plot.x + plot.width,
    );
    if (clipped.length === 0) {
        return;
    }

    ctx.strokeStyle = tickColor;
    ctx.fillStyle = tickColor;

    ctx.beginPath();
    for (const c of clipped) {
        ctx.moveTo(c.nearClip, rowTop + 3);
        ctx.lineTo(c.farClip, rowTop + 3);
        ctx.moveTo(c.nearEdge, rowTop);
        ctx.lineTo(c.nearEdge, rowTop + 3);
        ctx.moveTo(c.farEdge, rowTop);
        ctx.lineTo(c.farEdge, rowTop + 3);
    }

    ctx.stroke();

    ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
    const labelY = rowTop + 3 + 4;
    const kept: {
        x: number;
        y: number;
        width: number;
        height: number;
    }[] = [];
    const boundsRect = {
        x: plot.x,
        width: plot.width,
        y: rowTop,
        height: 9999,
    };

    for (const c of clipped) {
        const cx = (c.nearClip + c.farClip) / 2;

        const text = c.run.label;
        if (!text) {
            continue;
        }

        const available = c.farClip - c.nearClip - 4;
        const display = truncateLabel(ctx, text, available);
        if (!display) {
            continue;
        }

        const textWidth = ctx.measureText(display).width;
        const rect = labelRect(cx, labelY, textWidth, LABEL_LINE_HEIGHT, 0);
        if (!rectContained(rect, boundsRect)) {
            continue;
        }

        if (kept.some((r) => rectsOverlap(r, rect))) {
            continue;
        }

        kept.push(rect);

        drawLabel(ctx, display, cx, labelY, 0, "center");
    }
}

function leafRowsForRotated(visMin: number, visMax: number): number[] {
    const out: number[] = [];
    for (let i = visMin; i <= visMax; i++) {
        out.push(i);
    }

    return out;
}

export function renderCategoricalYTicks(
    ctx: Context2D,
    layout: PlotLayout,
    domain: CategoricalDomain,
    theme: Theme,
): void {
    if (domain.numRows === 0 || domain.levels.length === 0) {
        return;
    }

    const { tickColor, labelColor, fontFamily } = theme;
    const { plotRect: plot } = layout;
    const axisX = plot.x;

    ctx.strokeStyle = tickColor;
    ctx.fillStyle = tickColor;
    ctx.lineWidth = 1;
    ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;

    const widths = measureCategoricalLevelWidths(domain);
    const win = visibleRowWindow(
        domain.numRows,
        layout.paddedYMin,
        layout.paddedYMax,
        true,
    );
    if (!win) {
        return;
    }

    const [visMin, visMax] = win;

    const L = domain.levels.length;
    let xCursor = axisX;
    for (let l = L - 1; l >= 0; l--) {
        const level = domain.levels[l];
        const w = widths[l];
        const colRight = xCursor;
        xCursor -= w;

        if (l === L - 1) {
            renderLeafLevelY(
                ctx,
                layout,
                level,
                visMin,
                visMax,
                colRight,
                fontFamily,
                tickColor,
            );
        } else {
            renderOuterLevelY(
                ctx,
                layout,
                level,
                visMin,
                visMax,
                colRight,
                w,
                fontFamily,
                tickColor,
            );
        }
    }

    const axisLabel = domain.levelLabels.filter((s) => !!s).join(" / ");
    if (axisLabel) {
        ctx.fillStyle = labelColor;
        ctx.font = `13px ${fontFamily}`;
        ctx.save();
        ctx.translate(14, plot.y + plot.height / 2);
        ctx.rotate(-Math.PI / 2);
        ctx.textAlign = "center";
        ctx.textBaseline = "bottom";
        ctx.fillText(axisLabel, 0, 0);
        ctx.restore();
    }
}

function renderLeafLevelY(
    ctx: Context2D,
    layout: PlotLayout,
    level: CategoricalLevel,
    visMin: number,
    visMax: number,
    colRight: number,
    fontFamily: string,
    tickColor: string,
): void {
    const { plotRect: plot } = layout;

    const avgLabelHeight = LABEL_LINE_HEIGHT + 4;
    const count = visMax - visMin + 1;
    const maxLabels = Math.max(1, Math.floor(plot.height / avgLabelHeight));
    const step = count <= maxLabels ? 1 : Math.ceil(count / maxLabels);

    ctx.strokeStyle = tickColor;
    ctx.fillStyle = tickColor;
    ctx.beginPath();
    for (let r = visMin; r <= visMax; r += step) {
        const py = categoryIndexToPixelY(layout, r);
        if (py < plot.y - 1 || py > plot.y + plot.height + 1) {
            continue;
        }

        ctx.moveTo(colRight - TICK_SIZE, py);
        ctx.lineTo(colRight, py);
    }

    ctx.stroke();

    ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
    ctx.textAlign = "right";
    ctx.textBaseline = "middle";
    for (let r = visMin; r <= visMax; r += step) {
        const py = categoryIndexToPixelY(layout, r);
        if (py < plot.y - 1 || py > plot.y + plot.height + 1) {
            continue;
        }

        const text = getLeafText(level, r);
        if (!text) {
            continue;
        }

        ctx.fillText(text, colRight - TICK_SIZE - 3, py);
    }
}

function renderOuterLevelY(
    ctx: Context2D,
    layout: PlotLayout,
    level: CategoricalLevel,
    visMin: number,
    visMax: number,
    colRight: number,
    colWidth: number,
    fontFamily: string,
    tickColor: string,
): void {
    const { plotRect: plot } = layout;
    const runs = runsInRange(level.runs, visMin, visMax);
    if (runs.length === 0) {
        return;
    }

    const clipped = clippedRuns(
        runs,
        (idx) => categoryIndexToPixelY(layout, idx),
        plot.y,
        plot.y + plot.height,
    );
    if (clipped.length === 0) {
        return;
    }

    ctx.strokeStyle = tickColor;
    ctx.fillStyle = tickColor;

    const bracketX = colRight - 3;
    ctx.beginPath();
    for (const c of clipped) {
        ctx.moveTo(bracketX, c.nearClip);
        ctx.lineTo(bracketX, c.farClip);
        ctx.moveTo(bracketX, c.nearEdge);
        ctx.lineTo(colRight, c.nearEdge);
        ctx.moveTo(bracketX, c.farEdge);
        ctx.lineTo(colRight, c.farEdge);
    }

    ctx.stroke();

    ctx.font = `${LABEL_FONT_PX}px ${fontFamily}`;
    ctx.textAlign = "right";
    ctx.textBaseline = "middle";

    for (const c of clipped) {
        const cy = (c.nearClip + c.farClip) / 2;

        const text = c.run.label;
        if (!text) {
            continue;
        }

        const available = colWidth - 6;
        const display = truncateLabel(ctx, text, available);
        if (!display) {
            continue;
        }

        ctx.fillText(display, bracketX - 3, cy);
    }
}

function drawLabel(
    ctx: Context2D,
    text: string,
    px: number,
    py: number,
    rotation: 0 | 45 | 90,
    anchor: "center" | "end",
): void {
    if (rotation === 0) {
        ctx.textAlign = anchor === "end" ? "right" : "center";
        ctx.textBaseline = "top";
        ctx.fillText(text, px, py);
        return;
    }

    ctx.save();
    ctx.translate(px, py);
    ctx.rotate((-rotation * Math.PI) / 180);
    ctx.textAlign = "right";
    ctx.textBaseline = "middle";

    ctx.fillText(text, -2, 0);
    ctx.restore();
}
