import { fireEvent, render, screen, within } from "@testing-library/react";
import Donut, { type DonutSlice } from "./Donut";

function slice(key: string, value: number, color = "#000000"): DonutSlice {
	return { key, label: key.toUpperCase(), value, color };
}

function ring(container: HTMLElement): SVGPathElement[] {
	return Array.from(container.querySelectorAll("svg path"));
}

/** The x radius of every `A` command in a path, in the order they are drawn. */
function arcRadii(d: string): number[] {
	return Array.from(d.matchAll(/A(-?[\d.]+),/g)).map((match) => Number(match[1]));
}

function draw(slices: DonutSlice[]) {
	return render(<Donut slices={slices} caption="Queries by DNS type" unit="Queries" />);
}

test("a breakdown of nothing says so rather than dividing by zero", () => {
	const { container } = draw([slice("a", 0), slice("b", 0)]);
	expect(screen.getByText("No queries in this period.")).toBeTruthy();
	expect(container.querySelector("svg")).toBeNull();
});

test("an empty breakdown says the same thing", () => {
	draw([]);
	expect(screen.getByText("No queries in this period.")).toBeTruthy();
});

/** A legend entry reading 0% is noise, and a zero slice has no arc to draw. */
test("zero-valued entries are dropped from the ring, the legend and the table", () => {
	const { container } = draw([slice("a", 3), slice("z", 0), slice("b", 1)]);

	expect(ring(container)).toHaveLength(2);
	expect(screen.queryByText("Z")).toBeNull();
	expect(
		within(screen.getByRole("table"))
			.getAllByRole("rowheader")
			.map((row) => row.textContent),
	).toEqual(["A", "B"]);
});

/**
 * The caller has already ranked the slices, so the ring must not re-sort them:
 * the first arc opens at twelve o'clock and each one starts where the last
 * ended, running clockwise.
 *
 * The fixture runs 1 then 3 on purpose. d3's default pie sort is by value
 * descending, so a fixture that happened to be given in descending order would
 * pass with the sort left on and prove nothing.
 */
test("the ring starts at twelve o'clock and runs clockwise in the order given", () => {
	const { container } = draw([slice("a", 1, "#112233"), slice("b", 3, "#445566")]);

	const paths = ring(container);
	// The small slice is drawn first because it was given first.
	expect(paths[0].getAttribute("fill")).toBe("#112233");
	expect(paths[0].getAttribute("d")?.startsWith("M0,-90")).toBe(true);
	// A quarter turn clockwise from the top is three o'clock, where the second
	// slice picks up.
	expect(paths[1].getAttribute("fill")).toBe("#445566");
	expect(paths[1].getAttribute("d")?.startsWith("M90,0")).toBe(true);
});

/**
 * A whole-circle arc from a point back to itself draws nothing at all, so the
 * one-entry case has to come out as a closed ring: two half arcs out and two
 * back.
 */
test("a single entry is a closed ring, not a zero-length arc", () => {
	const { container } = draw([slice("only", 7)]);

	const paths = ring(container);
	expect(paths).toHaveLength(1);
	const d = paths[0].getAttribute("d") ?? "";
	expect(d.match(/A/g)).toHaveLength(4);
	// Two half arcs out at the outer radius and two back at the inner one: a
	// 180px ring 36px thick.
	expect(arcRadii(d)).toEqual([90, 90, 54, 54]);
});

test("shares are of the drawn total, in the legend and in the hidden table alike", () => {
	draw([slice("a", 3), slice("b", 1)]);

	expect(screen.getAllByText("75.0%")).toHaveLength(2);
	expect(screen.getAllByText("25.0%")).toHaveLength(2);
});

/**
 * The colour is the caller's, never recomputed from the key here: the page owns
 * the identity-to-colour mapping and two donuts of one page must agree with it.
 */
test("each arc is filled with the colour its slice carries", () => {
	const { container } = draw([slice("a", 3, "#112233"), slice("b", 1, "#445566")]);

	expect(ring(container).map((path) => path.getAttribute("fill"))).toEqual(["#112233", "#445566"]);
});

/**
 * Colour is a pure function of identity and so cannot rule out two slices of one
 * panel sharing a hue. The stroke is what stops neighbours from merging into one
 * shape.
 */
test("every arc is outlined in the panel's own surface colour", () => {
	const { container } = draw([slice("a", 3), slice("b", 1)]);

	for (const path of ring(container)) {
		expect(path.getAttribute("stroke-width")).toBe("1");
		expect(path.getAttribute("stroke")).toMatch(/^var\(--/);
	}
});

test("the ring is decoration; the legend and the hidden table are the accessible surface", () => {
	const { container } = draw([slice("a", 3), slice("b", 1)]);

	const svg = container.querySelector("svg");
	expect(svg?.getAttribute("aria-hidden")).toBe("true");
	expect(svg?.getAttribute("focusable")).toBe("false");
	expect(screen.getByRole("table", { name: "Queries by DNS type" })).toBeTruthy();
});

/**
 * The ring gets the bar charts' hover treatment: the shared tooltip names the
 * slice and repeats what the legend says about it, and the slices it is not
 * naming dim out of the way. The ring stays `aria-hidden` — the tooltip is a
 * pointer affordance, and the legend beside it is still the readable copy.
 */
test("pointing at a slice names it and dims the rest", () => {
	const { container } = draw([slice("a", 3, "#112233"), slice("b", 1, "#445566")]);
	const paths = ring(container);

	fireEvent.mouseOver(paths[0]);

	const tooltip = container.querySelector("dl") as HTMLElement;
	expect(tooltip.previousElementSibling?.textContent).toBe("A");
	expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual(["Queries", "Share"]);
	expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["3", "75.0%"]);
	// The same share the legend and the hidden table already print for this slice.
	expect(screen.getAllByText("75.0%")).toHaveLength(3);

	expect(paths.map((path) => path.getAttribute("opacity"))).toEqual(["1", "0.55"]);
	expect(container.querySelector("svg")?.getAttribute("aria-hidden")).toBe("true");

	// The tooltip points at the middle of the arc, which the component computes
	// from the slice values rather than from the drawn path. Slice A is three
	// quarters of the ring, so its midpoint is at 135 degrees, on a circle of
	// radius 72 — (140.9, 140.9) from the ring's top-left corner, plus the 8px
	// the tooltip stands off by. Nothing else here would catch that arithmetic
	// drifting away from the ring the Pie actually draws.
	const tooltipBox = container.querySelector(".visx-tooltip") as HTMLElement;
	expect(tooltipBox.style.transform).toBe("translate(149px, 149px)");
});

test("leaving the ring takes the tooltip and the dimming with it", () => {
	const { container } = draw([slice("a", 3), slice("b", 1)]);

	fireEvent.mouseOver(ring(container)[0]);
	expect(container.querySelectorAll("dl")).toHaveLength(1);

	fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);

	expect(container.querySelectorAll("dl")).toHaveLength(0);
	expect(ring(container).map((path) => path.getAttribute("opacity"))).toEqual(["1", "1"]);
});

/**
 * The same slice can change width between refreshes, so a mount measured at the
 * old width would be placed at the wrong one. This is the donut's half of the
 * remount the bar charts do.
 */
test("a refresh keeps the hovered slice current and remeasures it", () => {
	const { container, rerender } = draw([slice("a", 3), slice("b", 1)]);

	fireEvent.mouseOver(ring(container)[0]);
	const first = container.querySelector(".visx-tooltip");
	expect(Array.from(first?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual(["3", "75.0%"]);

	rerender(<Donut slices={[slice("a", 3000), slice("b", 1000)]} caption="Queries by DNS type" unit="Queries" />);

	const second = container.querySelector(".visx-tooltip");
	expect(Array.from(second?.querySelectorAll("dd") ?? []).map((value) => value.textContent)).toEqual([
		"3,000",
		"75.0%",
	]);
	expect(second).not.toBe(first);
});

/** A slice that is gone cannot be described, so the hover goes with it. */
test("a refresh that changes the slices takes the tooltip down", () => {
	const { container, rerender } = draw([slice("a", 3), slice("b", 1)]);

	fireEvent.mouseOver(ring(container)[0]);
	expect(container.querySelectorAll("dl")).toHaveLength(1);

	rerender(<Donut slices={[slice("c", 3), slice("b", 1)]} caption="Queries by DNS type" unit="Queries" />);

	expect(container.querySelectorAll("dl")).toHaveLength(0);
});
