import { fireEvent, render, screen, within } from "@testing-library/react";
import * as stylex from "@stylexjs/stylex";
import { formatTime } from "@/lib/format";
import type { Bucket, StatsTimeseries } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import TimeseriesChart from "./TimeseriesChart";

const SINCE = 1_700_000_000;

function timeseries(buckets: Bucket[]): StatsTimeseries {
	return {
		period: "24h",
		since: SINCE,
		until: SINCE + buckets.length * 1800,
		bucket_seconds: 1800,
		coverage: { complete: true, available_since: SINCE },
		buckets,
	};
}

function counting(bucketCount: number): StatsTimeseries {
	return timeseries(
		Array.from({ length: bucketCount }, (_, i) => ({
			ts: SINCE + i * 1800,
			queries: i + 1,
			blocked: 1,
			cached: 1,
		})),
	);
}

/** The element wearing the shared hidden style, found by its compiled classes. */
function hiddenElement(container: HTMLElement): Element | null {
	const classes = stylex.props(shared.srOnly).className?.split(" ").filter(Boolean) ?? [];
	expect(classes.length).toBeGreaterThan(0);
	return container.querySelector(classes.map((name) => `.${name}`).join(""));
}

/** The transparent per-bucket hit targets, in bucket order. */
function overlayRects(container: HTMLElement): SVGRectElement[] {
	return Array.from(container.querySelectorAll<SVGRectElement>('rect[fill="transparent"]'));
}

test("the data table is the SVG's accessible equivalent", () => {
	render(<TimeseriesChart data={counting(3)} />);

	expect(screen.getByRole("img", { name: /queries over time/i })).toBeTruthy();
	const table = screen.getByRole("table", { name: "Queries per time bucket" });
	expect(within(table).getAllByRole("row").length).toBe(4);
});

/**
 * `overflow` does not apply to a table box and `height` on one is a minimum, so
 * the hidden style has to sit on a block container wrapping the table. Worn by
 * the table itself it clips the paint but not the layout, and 48 invisible rows
 * push the document's scroll height a screen past the app shell.
 */
test("the hidden data table is clipped by a block wrapper, not by the table itself", () => {
	const { container } = render(<TimeseriesChart data={counting(48)} />);

	const hidden = hiddenElement(container);
	expect(hidden?.tagName).toBe("DIV");
	expect(hidden?.querySelector("table")).not.toBeNull();
});

/**
 * "Allowed" is what the reported total leaves over, and the three counts come
 * from separate columns that a partial write can leave inconsistent. A negative
 * remainder would draw a segment upside down.
 */
test("allowed is the remainder of the reported total, clamped at zero", () => {
	const { container } = render(
		<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 8, cached: 5 }])} />,
	);

	const row = within(screen.getByRole("table")).getAllByRole("row")[1];
	expect(
		within(row)
			.getAllByRole("cell")
			.map((cell) => cell.textContent),
	).toEqual(["10", "8", "5", "0"]);
	// Blocked and cached are drawn; the empty "allowed" segment is not.
	expect(container.querySelectorAll('rect[fill="#3b82f6"]')).toHaveLength(0);

	// The scale comes from the reported total, not from the stack's own sum.
	// Scaling to the sum would reach 13 here and leave the bar four fifths of the
	// way up a plot whose own numbers say it is full.
	const ticks = Array.from(container.querySelectorAll(".visx-axis-left text")).map((tick) => tick.textContent);
	expect(ticks[ticks.length - 1]).toBe("10");
});

test("a window with no queries says so instead of drawing an empty grid", () => {
	const { container } = render(
		<TimeseriesChart
			data={timeseries([
				{ ts: SINCE, queries: 0, blocked: 0, cached: 0 },
				{ ts: SINCE + 1800, queries: 0, blocked: 0, cached: 0 },
			])}
		/>,
	);

	expect(screen.getByText("No queries in this period.")).toBeTruthy();
	expect(container.querySelector("svg")).toBeNull();
});

test("no bucket at all says the same thing", () => {
	render(<TimeseriesChart data={timeseries([])} />);
	expect(screen.getByText("No queries in this period.")).toBeTruthy();
});

/**
 * The three category colours are fixed constants of this chart rather than
 * anything derived from `seriesColors`, which would paint "other" grey.
 */
test("the segments are drawn in this chart's own category colours", () => {
	const { container } = render(
		<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 100, blocked: 40, cached: 10 }])} />,
	);

	const fills = Array.from(container.querySelectorAll("rect"))
		.map((rect) => rect.getAttribute("fill"))
		.filter((fill) => fill !== "transparent");
	expect(fills).toEqual(["#ef4444", "#059669", "#3b82f6"]);
});

/**
 * The graphic carries no `<title>`: it duplicated the tooltip, the browser
 * showed it after its own delay and out of the app's styling, and the hidden
 * table is already the accessible equivalent.
 */
test("hover text is the tooltip alone, never a bare SVG title", () => {
	const { container } = render(<TimeseriesChart data={counting(3)} />);
	expect(container.querySelectorAll("title")).toHaveLength(0);
});

/** The hit target is the whole column slot, including the space above a short stack. */
test("each bucket's hit target spans the full plot height", () => {
	const { container } = render(<TimeseriesChart data={counting(3)} />);

	const rects = overlayRects(container);
	expect(rects).toHaveLength(3);
	for (const rect of rects) {
		expect(rect.getAttribute("y")).toBe("8");
		expect(rect.getAttribute("height")).toBe("210");
	}
});

/**
 * A band scale spends a gap after the last column as well as between them, so a
 * full-step hit target on the last bucket would reach into the right margin and
 * catch pointers that are past the plot entirely.
 */
test("the last hit target stops at the plot's right edge", () => {
	const { container } = render(<TimeseriesChart data={counting(3)} />);

	const last = overlayRects(container).at(-1) as SVGRectElement;
	const right = Number(last.getAttribute("x")) + Number(last.getAttribute("width"));
	// 640 fallback width, less the 44px left and 8px right margins.
	expect(right).toBeCloseTo(44 + 588, 6);
});

test("pointing at a bucket names its total and every series, and dims the rest", () => {
	const { container } = render(
		<TimeseriesChart
			data={timeseries([
				{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
				{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
			])}
		/>,
	);

	fireEvent.mouseOver(overlayRects(container)[0]);

	const tooltip = container.querySelector("dl") as HTMLElement;
	expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
	const values = Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent);
	expect(values).toEqual(["100", "40", "10", "50"]);
	const terms = Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent);
	expect(terms).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
	// One swatch per series, in the colour the segment is drawn in.
	const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
	expect(swatches[0]).toContain("#ef4444");
	expect(swatches[1]).toContain("#059669");
	expect(swatches[2]).toContain("#3b82f6");

	const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
	expect(stacks.map((group) => group.getAttribute("opacity"))).toEqual(["1", "0.55"]);
});

/**
 * The tooltip sits 8px down from the chart's top edge and 8px to the side of the
 * slot it names — the placement the hand-rolled tooltip had, restored over
 * visx's own 10px defaults.
 */
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
	const { container } = render(<TimeseriesChart data={counting(2)} />);

	fireEvent.mouseOver(overlayRects(container)[0]);

	// The first slot spans 44 to 44 + step, so its centre is 191.5 and the
	// tooltip sits 8px right of it. visx rounds the placement to whole pixels.
	const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
	expect(tooltip.style.transform).toBe("translate(200px, 8px)");
});

/**
 * `withBoundingRects` measures its node once, on mount. Sharing one mount across
 * buckets would place a wide bucket's tooltip with a narrow bucket's measured
 * width, which flips or clips it at the right-hand edge of the plot. Remounting
 * per bucket is what forces a fresh measurement; jsdom reports every rect as
 * zero, so the mount is what this can observe, not the measurement itself.
 */
test("each bucket gets its own tooltip mount, so each is measured for itself", () => {
	const { container } = render(<TimeseriesChart data={counting(2)} />);
	const rects = overlayRects(container);

	fireEvent.mouseOver(rects[0]);
	const first = container.querySelector(".visx-tooltip");
	fireEvent.mouseOver(rects[1]);
	const second = container.querySelector(".visx-tooltip");

	expect(first).not.toBeNull();
	expect(second).not.toBe(first);
});

/**
 * The window refreshes every half minute under an open tooltip. The tooltip
 * holds a bucket index and reads the numbers out of the render it is drawing, so
 * a refresh that keeps the same buckets updates it rather than leaving last
 * minute's counts on screen.
 */
test("a refresh in the same window retells the hovered bucket with the new counts", () => {
	const before = timeseries([
		{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
		{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
	]);
	const { container, rerender } = render(<TimeseriesChart data={before} />);

	fireEvent.mouseOver(overlayRects(container)[0]);
	expect(
		Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
	).toEqual(["100", "40", "10", "50"]);

	rerender(
		<TimeseriesChart
			data={timeseries([
				{ ts: SINCE, queries: 120, blocked: 60, cached: 10 },
				{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
			])}
		/>,
	);

	const values = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map(
		(dd) => dd.textContent,
	);
	expect(values).toEqual(["120", "60", "10", "50"]);
});

/**
 * A rolling window is the case a stored copy gets wrong: the bucket the pointer
 * was over is gone, so index 0 now names a different span. The tooltip goes away
 * rather than describing a bucket that is no longer drawn, and nothing stays
 * dimmed behind it. Rolling back to the earlier window must not bring it back
 * either: the selection is deleted when the window moves, not held aside.
 */
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
	const { container, rerender } = render(
		<TimeseriesChart
			data={timeseries([
				{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
				{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
			])}
		/>,
	);

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

	rerender(
		<TimeseriesChart
			data={{
				...timeseries([
					{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
					{ ts: SINCE + 3600, queries: 70, blocked: 7, cached: 7 },
				]),
				since: SINCE + 1800,
			}}
		/>,
	);

	expect(container.querySelectorAll("dl")).toHaveLength(0);
	const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
	expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);

	rerender(
		<TimeseriesChart
			data={timeseries([
				{ ts: SINCE, queries: 100, blocked: 40, cached: 10 },
				{ ts: SINCE + 1800, queries: 50, blocked: 5, cached: 5 },
			])}
		/>,
	);

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

/**
 * The same bucket can change width between refreshes — a count crossing a digit
 * boundary, a client name arriving — and a mount measured at the old width is
 * placed at the wrong one. A content change therefore remounts the tooltip, the
 * same way moving between buckets does.
 */
test("a bucket whose numbers change is remounted, so it is measured again", () => {
	const { container, rerender } = render(
		<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 9, blocked: 4, cached: 1 }])} />,
	);

	fireEvent.mouseOver(overlayRects(container)[0]);
	const first = container.querySelector(".visx-tooltip");

	rerender(<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 1000, blocked: 4, cached: 1 }])} />);
	const second = container.querySelector(".visx-tooltip");

	expect(first).not.toBeNull();
	expect(second).not.toBeNull();
	expect(second).not.toBe(first);
});

test("leaving the chart takes the tooltip and the dimming with it", () => {
	const { container } = render(<TimeseriesChart data={counting(3)} />);

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

	fireEvent.mouseOut(container.querySelector("svg") as SVGSVGElement);
	expect(container.querySelectorAll("dl")).toHaveLength(0);
	const stacks = Array.from(container.querySelectorAll("svg > g.visx-group[opacity]"));
	expect(stacks.every((group) => group.getAttribute("opacity") === "1")).toBe(true);
});

/**
 * The third series is every query neither blocked nor served from cache. It was
 * called "Other", which named the arithmetic rather than the thing.
 */
test("the remainder series is called Allowed everywhere it surfaces", () => {
	const { container } = render(
		<TimeseriesChart data={timeseries([{ ts: SINCE, queries: 10, blocked: 2, cached: 3 }])} />,
	);

	const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
	expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);
	expect(
		within(screen.getByRole("table"))
			.getAllByRole("columnheader")
			.map((cell) => cell.textContent),
	).toEqual(["Time", "Queries", "Blocked", "Cached", "Allowed"]);

	fireEvent.mouseOver(overlayRects(container)[0]);
	const terms = Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dt"));
	expect(terms.map((term) => term.textContent)).toEqual(["Queries", "Blocked", "Cached", "Allowed"]);
	expect(screen.queryByText("Other")).toBeNull();
});

/**
 * A window where everything was blocked or served from cache has no allowed
 * queries, and that is worth reading rather than hiding: an absent series would
 * say the same thing as a series nobody looked at. The three categories are all
 * real answers a query can get, so none of them is dropped for counting zero.
 * The client chart's "Other" is dropped at zero, but that one aggregates clients
 * beyond the top eight rather than naming a kind of answer.
 */
test("a window with nothing allowed keeps the series at zero", () => {
	const { container } = render(
		<TimeseriesChart
			data={timeseries([
				{ ts: SINCE, queries: 4, blocked: 4, cached: 0 },
				{ ts: SINCE + 1800, queries: 6, blocked: 6, cached: 0 },
			])}
		/>,
	);

	const legend = Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent);
	expect(legend).toEqual(["Blocked", "Cached", "Allowed"]);

	fireEvent.mouseOver(overlayRects(container)[0]);
	const tooltip = container.querySelector("dl") as HTMLElement;
	expect(Array.from(tooltip.querySelectorAll("dt")).map((term) => term.textContent)).toEqual([
		"Queries",
		"Blocked",
		"Cached",
		"Allowed",
	]);
	expect(Array.from(tooltip.querySelectorAll("dd")).map((value) => value.textContent)).toEqual(["4", "4", "0", "0"]);
});
