/**
 * A breakdown as a ring, a legend and a table.
 *
 * The ring is decoration: it carries `aria-hidden` and `focusable="false"`,
 * because a non-focusable SVG is still in the accessibility tree and would
 * announce a pile of unlabelled paths. Everything the ring says is said again in
 * the legend — visibly, with the share and the count — and once more in a
 * visually hidden table, which is the surface a screen reader reads.
 *
 * Labels can collide: two rows can both be "Unknown", and one upstream name can
 * appear under two route kinds. Identity is therefore the caller's `key`, and an
 * entry that needs disambiguating carries `secondary` text saying which it is.
 */

import * as stylex from "@stylexjs/stylex";
import { Group } from "@visx/group";
import { Pie } from "@visx/shape";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";
import { ChartTooltip, useActiveIndex, type TooltipContent } from "./chartKit";

export interface DonutSlice {
	/** Semantic identity: the React key, the colour key and the legend's identity. */
	key: string;
	label: string;
	/** Disambiguates entries whose labels collide — two "Unknown"s, one name on two route kinds. */
	secondary?: string;
	value: number;
	color: string;
}

const SIZE = 180;
const THICKNESS = 36;
const OUTER_RADIUS = SIZE / 2;
const INNER_RADIUS = OUTER_RADIUS - THICKNESS;

const numberFormat = new Intl.NumberFormat();

/** The width at which the page puts the two donuts side by side, and the page's
 * own grid switches on the same query. StyleX will not take it from an import,
 * so it is written out in both modules and must be changed in both. */
const TWO_COLUMN = "@media (min-width: 1280px)";

const styles = stylex.create({
	empty: {
		display: "flex",
		alignItems: "center",
		justifyContent: "center",
		minHeight: SIZE,
		borderRadius: "0.25rem",
		borderWidth: 1,
		borderStyle: "dashed",
		borderColor: colors.borderStrong,
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
		color: colors.textMuted,
	},
	/**
	 * Centred while the panels are stacked, left-anchored once they are side by
	 * side. Stacked, the panel is as wide as the page and a ring pinned to the
	 * left edge reads as a mistake; in a column it is one of a pair and lines up
	 * with everything above it.
	 */
	body: {
		display: "flex",
		flexWrap: "wrap",
		alignItems: "center",
		justifyContent: { default: "center", [TWO_COLUMN]: "flex-start" },
		gap: "1.25rem",
	},
	/** The tooltip is placed against the ring's own box, so slice coordinates can
	 * be used unchanged rather than measured against the whole panel. */
	ring: {
		position: "relative",
		flexShrink: 0,
		lineHeight: 0,
	},
	/**
	 * Capped and left-anchored. Without the cap the row justifies across whatever
	 * the panel is given — most of a metre of whitespace on a wide monitor — and a
	 * label stops reading as belonging to the count opposite it.
	 */
	legend: {
		flex: 1,
		minWidth: "12rem",
		maxWidth: "24rem",
		display: "flex",
		flexDirection: "column",
		gap: "0.25rem",
		listStyleType: "none",
		padding: 0,
		margin: 0,
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
	},
	legendItem: {
		display: "flex",
		alignItems: "baseline",
		gap: "0.5rem",
	},
	swatch: {
		flexShrink: 0,
		alignSelf: "center",
		display: "inline-block",
		width: "0.625rem",
		height: "0.625rem",
		borderRadius: "0.125rem",
	},
	/** Dynamic: the swatch takes the colour the ring is drawn in. */
	swatchColor: (color: string) => ({ backgroundColor: color }),
	label: {
		flex: 1,
		minWidth: 0,
		overflowWrap: "anywhere",
	},
	secondary: {
		marginLeft: "0.375rem",
		fontSize: "0.75rem",
		lineHeight: "1rem",
		color: colors.textMuted,
	},
	count: {
		color: colors.textSecondary,
	},
	share: {
		minWidth: "3rem",
		textAlign: "right",
		color: colors.textMuted,
	},
});

function sharePercent(share: number): string {
	return `${(share * 100).toFixed(1)}%`;
}

/**
 * Where a slice's tooltip points: the middle of its arc, in the ring box's own
 * coordinates. This restates the `Pie` configuration below — clockwise from
 * twelve o'clock, caller order, no pad angle — and the two must agree.
 */
function sliceAnchor(drawn: DonutSlice[], index: number, total: number): { left: number; top: number } {
	const before = drawn.slice(0, index).reduce((sum, slice) => sum + slice.value, 0);
	const middle = (2 * Math.PI * (before + drawn[index].value / 2)) / total;
	const radius = (OUTER_RADIUS + INNER_RADIUS) / 2;
	return {
		left: OUTER_RADIUS + Math.sin(middle) * radius,
		top: OUTER_RADIUS - Math.cos(middle) * radius,
	};
}

export default function Donut({
	slices,
	caption,
	unit,
}: {
	slices: DonutSlice[];
	/** Names the hidden table, so a screen reader knows which breakdown it is in. */
	caption: string;
	/** The column header for the counted thing, e.g. "Queries". */
	unit: string;
}) {
	// Zero-valued entries have no arc to draw and a legend entry reading 0 is
	// noise, so they are dropped before anything is measured or drawn.
	const drawn = slices.filter((slice) => slice.value > 0);
	const total = drawn.reduce((sum, slice) => sum + slice.value, 0);
	// A hover names a slice by position, so it survives only while the ring is
	// still made of the same slices in the same order.
	const hovered = useActiveIndex(drawn.map((slice) => slice.key).join(","));

	function tooltipOf(index: number): TooltipContent {
		const slice = drawn[index];
		return {
			title: slice.secondary === undefined ? slice.label : `${slice.label} (${slice.secondary})`,
			rows: [
				{ key: "value", label: unit, color: slice.color, value: numberFormat.format(slice.value) },
				{ key: "share", label: "Share", value: sharePercent(slice.value / total) },
			],
		};
	}

	if (total === 0) {
		return <div {...stylex.props(styles.empty)}>No queries in this period.</div>;
	}

	return (
		<div {...stylex.props(styles.body)}>
			<div {...stylex.props(styles.ring)}>
				{/* The ring stays out of the accessibility tree even though it is now
				    a pointer target: the tooltip repeats what the legend beside it
				    already says in text, so nothing here is the only copy. */}
				<svg
					aria-hidden="true"
					focusable="false"
					width={SIZE}
					height={SIZE}
					viewBox={`0 0 ${SIZE} ${SIZE}`}
					onMouseLeave={hovered.clear}
				>
					{/* Arc paths are generated around the origin, and `Pie`'s own
				    `top`/`left` group is skipped when it is given a render prop, so
				    the ring is centred here instead. */}
					<Group top={OUTER_RADIUS} left={OUTER_RADIUS}>
						<Pie
							data={drawn}
							pieValue={(slice) => slice.value}
							// The caller has already ranked the slices, so d3's own sort is off
							// in both of its forms and the ring runs clockwise from twelve
							// o'clock in the order given. @visx/shape 4.0.0 already disables
							// it by default — but that default exists because d3-shape v3
							// changed sortValues to descending under it, so saying it here is
							// what keeps a future bump from silently re-ranking the ring.
							pieSort={null}
							pieSortValues={null}
							outerRadius={OUTER_RADIUS}
							innerRadius={INNER_RADIUS}
						>
							{({ arcs, path }) =>
								arcs.map((arc, index) => (
									// The stroke is what keeps a shared hue from lying. Colour is a
									// pure function of identity, so two neighbouring slices can come
									// out the same; outlined in the panel's own colour they still
									// read as two shapes rather than merging into one. Attributes
									// rather than a class, as the client chart's segments are, so
									// the separation is visible to a test and not only to a
									// stylesheet.
									<path
										key={arc.data.key}
										d={path(arc) ?? ""}
										fill={arc.data.color}
										stroke={colors.surfaceRaised}
										strokeWidth={1}
										opacity={hovered.index === null || hovered.index === index ? 1 : 0.55}
										onMouseEnter={() => hovered.show(index)}
									/>
								))
							}
						</Pie>
					</Group>
				</svg>
				{hovered.index !== null && (
					<ChartTooltip
						index={hovered.index}
						content={tooltipOf(hovered.index)}
						{...sliceAnchor(drawn, hovered.index, total)}
					/>
				)}
			</div>
			<ul {...stylex.props(styles.legend)}>
				{drawn.map((slice) => (
					<li key={slice.key} {...stylex.props(styles.legendItem)}>
						<span aria-hidden="true" {...stylex.props(styles.swatch, styles.swatchColor(slice.color))} />
						<span {...stylex.props(styles.label)}>
							{slice.label}
							{slice.secondary !== undefined && (
								<span {...stylex.props(styles.secondary)}>{slice.secondary}</span>
							)}
						</span>
						<span {...stylex.props(styles.count, shared.tabularNums)}>
							{numberFormat.format(slice.value)}
						</span>
						<span {...stylex.props(styles.share, shared.tabularNums)}>
							{sharePercent(slice.value / total)}
						</span>
					</li>
				))}
			</ul>
			<div {...stylex.props(shared.srOnly)}>
				<table>
					<caption>{caption}</caption>
					<thead>
						<tr>
							<th scope="col">Entry</th>
							<th scope="col">{unit}</th>
							<th scope="col">Share</th>
						</tr>
					</thead>
					<tbody>
						{drawn.map((slice) => (
							<tr key={slice.key}>
								<th scope="row">
									{slice.secondary === undefined
										? slice.label
										: `${slice.label} (${slice.secondary})`}
								</th>
								<td>{slice.value}</td>
								<td>{sharePercent(slice.value / total)}</td>
							</tr>
						))}
					</tbody>
				</table>
			</div>
		</div>
	);
}
