/**
 * The plumbing the two bar charts on Overview share: the width measurement, the
 * scales, the axis and grid chrome, the segment separator, the per-bucket hit
 * target and the tooltip.
 *
 * Geometry is visx's; the chrome is ours. visx's own axis defaults draw tick
 * marks, an axis line on both axes and Arial 10px in #222, none of which this
 * app wants, so every wrapper here turns those off and dresses the result in
 * StyleX tokens instead.
 */

import { useEffect, useRef, useState } from "react";
import * as stylex from "@stylexjs/stylex";
import { AxisBottom, AxisLeft, type TickRendererProps } from "@visx/axis";
import { GridRows } from "@visx/grid";
import { scaleBand, scaleLinear } from "@visx/scale";
import { TooltipWithBounds } from "@visx/tooltip";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";

export const CHART_HEIGHT = 240;
export const MARGIN = { top: 8, right: 8, bottom: 22, left: 44 } as const;

/** The width a chart draws at before a measurement exists — jsdom, and the first paint. */
const FALLBACK_WIDTH = 640;

/** How many pixels one x-axis label needs to itself before the labels thin out. */
const MIN_X_LABEL_PX = 90;

/** The gap between two touching columns, in pixels of the drawn plot. */
const BAR_GAP_PX = 2;

const Y_TICK_COUNT = 5;

export interface Plot {
	x: number;
	y: number;
	width: number;
	height: number;
	/** The y of the baseline: the bottom edge of the plot. */
	bottom: number;
}

export function plotArea(width: number): Plot {
	const height = Math.max(0, CHART_HEIGHT - MARGIN.top - MARGIN.bottom);
	return {
		x: MARGIN.left,
		y: MARGIN.top,
		width: Math.max(0, width - MARGIN.left - MARGIN.right),
		height,
		bottom: MARGIN.top + height,
	};
}

/**
 * The container's width, measured on mount and on every resize. Deliberately
 * `useEffect` rather than `useLayoutEffect`: the first paint draws at the
 * fallback width and the measured width lands a frame later, which is the
 * timing the charts have always had.
 */
export function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
	const ref = useRef<HTMLDivElement>(null);
	const [width, setWidth] = useState(0);
	useEffect(() => {
		const el = ref.current;
		if (el === null) return;
		setWidth(el.clientWidth);
		if (typeof ResizeObserver === "undefined") return;
		const observer = new ResizeObserver(() => setWidth(el.clientWidth));
		observer.observe(el);
		return () => observer.disconnect();
	}, []);
	return [ref, width > 0 ? width : FALLBACK_WIDTH];
}

/**
 * The value axis. `nice: true` rounds the domain outward, so the `max` passed
 * here is the data's own maximum and not a pre-rounded one.
 */
export function valueScale(max: number, range: [number, number]) {
	return scaleLinear<number>({ domain: [0, max], range, nice: true });
}

export type ValueScale = ReturnType<typeof valueScale>;

/** The tick values the grid and the value axis both draw. */
export function valueTicks(scale: ValueScale): number[] {
	return scale.ticks(Y_TICK_COUNT);
}

/**
 * How wide the gap between two columns is, as a fraction of one column's slot.
 * A constant would make the gap grow with the column: 30 daily columns and 60
 * minute columns are drawn at the same pixel gap, so the fraction has to be
 * computed from how many columns share the plot.
 */
export function bandPaddingInner(bucketCount: number, plotWidth: number): number {
	if (bucketCount === 0 || plotWidth <= 0) return 0;
	return Math.min(0.5, (BAR_GAP_PX * bucketCount) / plotWidth);
}

/** The time axis: one band per bucket, in the order the buckets were given. */
export function bandScale(values: number[], plot: Plot) {
	return scaleBand<number>({
		domain: values,
		range: [plot.x, plot.x + plot.width],
		paddingInner: bandPaddingInner(values.length, plot.width),
	});
}

export type BandScale = ReturnType<typeof bandScale>;

/**
 * The subset of bucket timestamps that get an x-axis label. A 30-day window is
 * 30 columns and a 1-hour window is 60, so at narrow widths the labels have to
 * thin out rather than overprint each other.
 */
export function labelTickValues(values: number[], plotWidth: number): number[] {
	if (values.length === 0 || plotWidth <= 0) return values.slice(0, 1);
	const step = Math.max(1, Math.ceil((values.length * MIN_X_LABEL_PX) / plotWidth));
	return values.filter((_, i) => i % step === 0);
}

const compact = new Intl.NumberFormat(undefined, { notation: "compact" });

export function formatBucketTime(ts: number, bucketSeconds: number): string {
	const date = new Date(ts * 1000);
	if (bucketSeconds >= 86_400) {
		return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
	}
	return new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" }).format(date);
}

const styles = stylex.create({
	empty: {
		display: "flex",
		alignItems: "center",
		justifyContent: "center",
		height: CHART_HEIGHT,
		borderRadius: "0.25rem",
		borderWidth: 1,
		borderStyle: "dashed",
		borderColor: colors.borderStrong,
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
		color: colors.textMuted,
	},
	root: {
		position: "relative",
	},
	axisLabel: {
		fill: colors.textMuted,
		fontSize: "10px",
	},
	/** The hairline separating touching segments is the page ground, not a colour. */
	segment: {
		stroke: colors.surface,
	},
	tooltip: {
		pointerEvents: "none",
		position: "absolute",
		zIndex: 10,
		borderRadius: "0.25rem",
		borderWidth: 1,
		borderStyle: "solid",
		borderColor: colors.borderStrong,
		backgroundColor: colors.surfaceRaised,
		paddingInline: "0.75rem",
		paddingBlock: "0.5rem",
		fontSize: "0.75rem",
		lineHeight: "1rem",
		boxShadow: "0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)",
	},
	tooltipTitle: {
		fontWeight: 500,
	},
	tooltipList: {
		display: "flex",
		flexDirection: "column",
		gap: "0.125rem",
		marginTop: "0.25rem",
	},
	tooltipRow: {
		display: "flex",
		alignItems: "center",
		justifyContent: "space-between",
		gap: "1rem",
	},
	tooltipTerm: {
		display: "flex",
		alignItems: "center",
		gap: "0.375rem",
		color: colors.textMuted,
	},
	swatch: {
		display: "inline-block",
		width: "0.5rem",
		height: "0.5rem",
		borderRadius: "0.125rem",
	},
	/** Dynamic: the swatch takes the series colour the SVG bars are drawn in. */
	swatchColor: (color: string) => ({ backgroundColor: color }),
});

/** Both charts' "nothing here" state, which is also where the ref to measure lives. */
export function EmptyChart({ containerRef }: { containerRef: React.RefObject<HTMLDivElement | null> }) {
	return (
		<div ref={containerRef} {...stylex.props(styles.empty)}>
			No queries in this period.
		</div>
	);
}

export function ChartRoot({
	containerRef,
	children,
}: {
	containerRef: React.RefObject<HTMLDivElement | null>;
	children: React.ReactNode;
}) {
	return (
		<div ref={containerRef} {...stylex.props(styles.root)}>
			{children}
		</div>
	);
}

/**
 * visx's `Ticks` derives a label's `y` from a guessed font size, which is wrong
 * here because the size comes from a stylesheet it cannot read. The label is
 * therefore placed by the axis group's own transform plus the `dy` the caller
 * passes, and the guessed `y` is dropped.
 */
function TickLabel({ x, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
	return (
		<text
			x={x}
			y={0}
			dx={dx}
			dy={dy}
			textAnchor={textAnchor}
			dominantBaseline={dominantBaseline}
			{...stylex.props(styles.axisLabel)}
		>
			{formattedValue}
		</text>
	);
}

/** As `TickLabel`, but keeping the scaled tick position the vertical axis puts in `y`. */
function ValueTickLabel({ x, y, dx, dy, textAnchor, dominantBaseline, formattedValue }: TickRendererProps) {
	return (
		<text
			x={x}
			y={y}
			dx={dx}
			dy={dy}
			textAnchor={textAnchor}
			dominantBaseline={dominantBaseline}
			{...stylex.props(styles.axisLabel, shared.tabularNums)}
		>
			{formattedValue}
		</text>
	);
}

/**
 * The grid and the two axes. The grid lines and the value labels are computed
 * from one tick array, so every drawn line has a number beside it and no number
 * floats without one.
 */
export function ChartFrame({
	plot,
	yScale,
	yTicks,
	xScale,
	xTickValues,
	bucketSeconds,
}: {
	plot: Plot;
	yScale: ValueScale;
	yTicks: number[];
	xScale: BandScale;
	xTickValues: number[];
	bucketSeconds: number;
}) {
	return (
		<>
			<GridRows scale={yScale} tickValues={yTicks} left={plot.x} width={plot.width} stroke={colors.border} />
			<AxisLeft
				scale={yScale}
				left={plot.x}
				tickValues={yTicks}
				numTicks={Y_TICK_COUNT}
				hideAxisLine
				hideTicks
				tickLength={0}
				tickFormat={(value) => compact.format(Number(value))}
				// `dy` overrides AxisLeft's own 0.25em nudge, which would double up
				// with the middle baseline this app centres its value labels on.
				tickLabelProps={{ dx: "-6px", dy: 0, textAnchor: "end", dominantBaseline: "middle" }}
				tickComponent={ValueTickLabel}
			/>
			<AxisBottom
				scale={xScale}
				top={plot.bottom}
				tickValues={xTickValues}
				hideTicks
				tickLength={0}
				stroke={colors.borderStrong}
				tickFormat={(value) => formatBucketTime(Number(value), bucketSeconds)}
				tickLabelProps={{ dy: "16px", textAnchor: "middle" }}
				tickComponent={TickLabel}
			/>
		</>
	);
}

/**
 * One segment of a stacked column. The separator is drawn only once the column
 * is wide enough for two neighbouring segments to read as two shapes; below
 * that it would be most of the bar.
 */
export function StackSegment({
	x,
	y,
	width,
	height,
	fill,
}: {
	x: number;
	y: number;
	width: number;
	height: number;
	fill: string;
}) {
	if (height <= 0) return null;
	return (
		<rect
			x={x}
			y={y}
			width={width}
			height={height}
			fill={fill}
			strokeWidth={width > 3 ? 1 : 0}
			{...stylex.props(styles.segment)}
		/>
	);
}

/**
 * The transparent hit targets: one per bucket, spanning the whole plot height so
 * that pointing at the empty space above a short column still selects it.
 *
 * A slot is a whole band step, gap included, so that no pixel between two
 * columns belongs to neither. The last slot is clipped to the plot's right edge:
 * the band scale spends the trailing gap on nothing, and a full-step rect there
 * would reach into the right margin.
 */
export function BucketOverlay({
	plot,
	values,
	xScale,
	onEnter,
}: {
	plot: Plot;
	values: number[];
	xScale: BandScale;
	onEnter: (index: number) => void;
}) {
	const step = xScale.step();
	const right = plot.x + plot.width;
	return (
		<>
			{values.map((value, index) => {
				const x = xScale(value) ?? plot.x;
				return (
					<rect
						key={value}
						x={x}
						y={plot.y}
						width={Math.max(0, Math.min(step, right - x))}
						height={plot.height}
						fill="transparent"
						onMouseEnter={() => onEnter(index)}
					/>
				);
			})}
		</>
	);
}

/** The x a bucket's tooltip points at: the centre of its slot, not of its narrower bar. */
export function slotCenter(xScale: BandScale, value: number, plot: Plot): number {
	return (xScale(value) ?? plot.x) + xScale.step() / 2;
}

/**
 * Which item the pointer is on, and nothing else — a bucket in the bar charts, a
 * slice in the donuts.
 *
 * Deliberately not `useTooltip`: holding the hovered item's numbers and screen
 * position in state decouples them from the data. The window refreshes every
 * half minute and the container can be resized under an open tooltip, and either
 * one leaves a stored copy describing an item that has moved or changed. Only
 * the index is kept, so the caller reads the values and the coordinates out of
 * the render it is currently drawing.
 *
 * `windowKey` is what makes a changing set safe: when the items themselves
 * change, index `i` no longer names the one the pointer was over, so the
 * selection made against the old key is dropped and the tooltip goes away.
 */
export function useActiveIndex(windowKey: string) {
	const [selection, setSelection] = useState<{ index: number; key: string } | null>(null);
	// A selection belongs to the window it was made in. Deleting it as soon as the
	// key moves on is what stops a returning key — a resize back to a previous
	// width, a poll that restores a bucket count — from reviving a hover the
	// pointer never made.
	if (selection !== null && selection.key !== windowKey) setSelection(null);
	return {
		index: selection !== null && selection.key === windowKey ? selection.index : null,
		show: (index: number) => setSelection({ index, key: windowKey }),
		clear: () => setSelection(null),
	};
}

export interface TooltipRow {
	key: string;
	label: string;
	/** Omitted for a row that names no drawn shape, such as a bucket's total. */
	color?: string;
	value: string;
}

/** What a tooltip says, whichever chart opened it: a heading and its rows. */
export interface TooltipContent {
	title: string;
	rows: TooltipRow[];
}

/**
 * `TooltipWithBounds` positions itself with an inline transform and drops it
 * when `unstyled` is set, so the default look is replaced by handing it an empty
 * style object rather than by turning styling off.
 */
const NO_INLINE_STYLE = {};

/** The gap the tooltip keeps from its anchor point. */
const TOOLTIP_OFFSET = 8;

export function ChartTooltip({
	content,
	index,
	left,
	top = 0,
}: {
	content: TooltipContent;
	/** Which item the tooltip names; part of what forces a fresh measurement. */
	index: number;
	left: number;
	top?: number;
}) {
	// `withBoundingRects` measures once, in `componentDidMount`, and never again,
	// so every content change needs its own mount to be measured at its own size.
	// The index alone is not enough: a refresh can widen the same item's text past
	// a digit boundary, or resolve a client's name, and the stale width would place
	// it wrongly at the right edge.
	const measureKey = [index, content.title, ...content.rows.map((row) => `${row.label}=${row.value}`)].join("|");
	return (
		<TooltipWithBounds
			key={measureKey}
			left={left}
			top={top}
			offsetLeft={TOOLTIP_OFFSET}
			offsetTop={TOOLTIP_OFFSET}
			style={NO_INLINE_STYLE}
			className={stylex.props(styles.tooltip).className}
		>
			<div {...stylex.props(styles.tooltipTitle)}>{content.title}</div>
			<dl {...stylex.props(styles.tooltipList)}>
				{content.rows.map((row) => (
					<div key={row.key} {...stylex.props(styles.tooltipRow)}>
						<dt {...stylex.props(styles.tooltipTerm)}>
							{row.color !== undefined && (
								<span
									aria-hidden="true"
									{...stylex.props(styles.swatch, styles.swatchColor(row.color))}
								/>
							)}
							{row.label}
						</dt>
						<dd {...stylex.props(shared.tabularNums)}>{row.value}</dd>
					</div>
				))}
			</dl>
		</TooltipWithBounds>
	);
}
