import { fireEvent, render as renderBare, screen, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import { formatTime } from "@/lib/format";
import ClientChart, { type ClientChartData } from "./ClientChart";
import { OTHER_KEY, clientKey, seriesColor } from "./seriesColors";

const SINCE = 1_700_000_000;
const BUCKET = 1800;

function clients(named: { client: string; buckets: number[] }[], other: number[]): ClientChartData {
	return { since: SINCE, bucket_seconds: BUCKET, clients: named, other };
}

const TWO_BUCKETS = clients(
	[
		{ client: "192.0.2.30", buckets: [10, 1] },
		{ client: "192.0.2.31", buckets: [20, 1] },
	],
	[5, 1],
);

/**
 * The chart looks a client's registered name up, so it needs a query client. No
 * client is registered in these fixtures, which is what leaves the addresses on
 * screen as the labels.
 */
function render(data: ClientChartData) {
	const client = createQueryClient();
	const tree = (next: ClientChartData) => (
		<QueryClientProvider client={client}>
			<ClientChart data={next} />
		</QueryClientProvider>
	);
	const result = renderBare(tree(data));
	return { ...result, rerender: (next: ClientChartData) => result.rerender(tree(next)) };
}

beforeEach(() => {
	vi.stubGlobal(
		"fetch",
		vi.fn(async () =>
			Promise.resolve(
				new Response(JSON.stringify({ clients: [] }), {
					status: 200,
					headers: { "content-type": "application/json" },
				}),
			),
		),
	);
});

afterEach(() => vi.unstubAllGlobals());

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(TWO_BUCKETS);

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

/**
 * The x-axis is this response's own `since` plus one bucket width per column.
 * The timeseries endpoint aligns its buckets with these, which is what lets the
 * two charts stack above one another without either knowing about the other.
 */
test("the columns are timestamped from this response's own window", () => {
	render(TWO_BUCKETS);

	const rows = within(screen.getByRole("table")).getAllByRole("rowheader");
	expect(rows.map((row) => row.textContent)).toEqual([formatTime(SINCE), formatTime(SINCE + BUCKET)]);
});

/**
 * Every series here is a disjoint part of the whole, so the scale has to come
 * from the tallest column's own sum. Taking it from the largest single value
 * instead would run the tallest column off the top of the plot: the axis has to
 * reach 35 here, not 20.
 */
test("the value scale covers the tallest column's total, not its largest series", () => {
	const { container } = render(clients([{ client: "192.0.2.30", buckets: [20] }], [15]));

	const labels = Array.from(container.querySelectorAll(".visx-axis-left text")).map((label) => label.textContent);
	expect(labels[labels.length - 1]).toBe("35");
});

test("the series are drawn in the colour of the client's address, and Other in its own", () => {
	const { container } = render(clients([{ client: "192.0.2.30", buckets: [10] }], [5]));

	const fills = Array.from(container.querySelectorAll("rect"))
		.map((rect) => rect.getAttribute("fill"))
		.filter((fill) => fill !== "transparent");
	expect(fills).toEqual([seriesColor(clientKey("192.0.2.30")), seriesColor(OTHER_KEY)]);
});

test("a window with no queries says so instead of drawing an empty grid", () => {
	const { container } = render(clients([{ client: "192.0.2.30", buckets: [0, 0] }], [0, 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(clients([], []));
	expect(screen.getByText("No queries in this period.")).toBeTruthy();
});

test("hover text is the tooltip alone, never a bare SVG title", () => {
	const { container } = render(TWO_BUCKETS);
	expect(container.querySelectorAll("title")).toHaveLength(0);
});

test("each bucket's hit target spans the full plot height", () => {
	const { container } = render(TWO_BUCKETS);

	const rects = overlayRects(container);
	expect(rects).toHaveLength(2);
	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.
 */
test("the last hit target stops at the plot's right edge", () => {
	const { container } = render(TWO_BUCKETS);

	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);
});

/**
 * 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 { container, rerender } = render(TWO_BUCKETS);

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

	rerender(
		clients(
			[
				{ client: "192.0.2.30", buckets: [11, 1] },
				{ client: "192.0.2.31", buckets: [22, 1] },
			],
			[6, 1],
		),
	);

	expect(
		Array.from((container.querySelector("dl") as HTMLElement).querySelectorAll("dd")).map((dd) => dd.textContent),
	).toEqual(["39", "11", "22", "6"]);
});

/**
 * 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.
 */
test("a refresh that rolls the window takes the tooltip down instead of relabelling it", () => {
	const { container, rerender } = render(TWO_BUCKETS);

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

	rerender({ ...TWO_BUCKETS, since: SINCE + BUCKET });

	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 placement the hand-rolled tooltip had, restored over visx's 10px defaults. */
test("the tooltip is offset 8px from the chart top and from the bucket it names", () => {
	const { container } = render(TWO_BUCKETS);

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

	const tooltip = container.querySelector(".visx-tooltip") as HTMLElement;
	expect(tooltip.style.transform).toBe("translate(200px, 8px)");
});

/**
 * `withBoundingRects` measures its node once, on mount, so the buckets must not
 * share one. 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(TWO_BUCKETS);
	const rects = overlayRects(container);

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

	expect(first).not.toBeNull();
	expect(container.querySelector(".visx-tooltip")).not.toBe(first);
});

test("pointing at a bucket names its total and every series, and dims the rest", () => {
	const { container } = render(TWO_BUCKETS);

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

	const tooltip = container.querySelector("dl") as HTMLElement;
	expect(tooltip.previousElementSibling?.textContent).toBe(formatTime(SINCE));
	expect(Array.from(tooltip.querySelectorAll("dt")).map((dt) => dt.textContent)).toEqual([
		"Queries",
		"192.0.2.30",
		"192.0.2.31",
		"Other",
	]);
	expect(Array.from(tooltip.querySelectorAll("dd")).map((dd) => dd.textContent)).toEqual(["35", "10", "20", "5"]);
	const swatches = Array.from(tooltip.querySelectorAll("dt span")).map((span) => span.getAttribute("style"));
	expect(swatches[0]).toContain(seriesColor(clientKey("192.0.2.30")));
	expect(swatches[2]).toContain(seriesColor(OTHER_KEY));

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

test("leaving the chart takes the tooltip and the dimming with it", () => {
	const { container } = render(TWO_BUCKETS);

	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);
});

/**
 * "Other" is everything outside the top eight clients. In a window where it
 * counted nothing there is no eighth client to aggregate, and the entry would
 * appear in the legend, the stack, the tooltip and the table saying only that it
 * is empty. The named clients stay at zero: a client that went quiet is a fact.
 */
test("a window where Other counted nothing drops it from every surface", () => {
	const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 0]));

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

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

test("one query outside the named clients is enough to keep Other", () => {
	const { container } = render(clients([{ client: "192.0.2.30", buckets: [10, 4] }], [0, 1]));

	expect(Array.from(container.querySelectorAll("ul li")).map((item) => item.textContent)).toEqual([
		"192.0.2.30",
		"Other",
	]);
});
