/**
 * Window coherence across the five Overview requests, migrated from the
 * two-request `activityWindow` this replaces. Every behaviour that hook pinned
 * is pinned here — the identity, the one retry per mismatch episode, the
 * terminal error, the discarded previous-period pair and the stale completion
 * that must not speak — now over five endpoints and with the watermark in the
 * identity, plus the per-panel isolation the layout added.
 */

import { render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { createQueryClient } from "@/lib/queryClient";
import type { Coverage, Period } from "@/lib/types";
import {
	newerWindow,
	sameWindow,
	useOverviewWindow,
	windowIdOf,
	OVERVIEW_ENDPOINTS,
	type OverviewEndpoint,
} from "./overviewWindow";

const SINCE = Date.UTC(2026, 0, 1, 0, 0) / 1000;
const UNTIL = Date.UTC(2026, 0, 2, 0, 0) / 1000;
const COVERAGE: Coverage = { complete: true, available_since: SINCE };

/** Where each endpoint's body currently ends, and what watermark it admits. */
interface Bounds {
	until: number;
	availableSince: number;
}

const PATHS: Record<OverviewEndpoint, string> = {
	totals: "/api/stats?period=",
	timeseries: "/api/stats/timeseries?period=",
	clients: "/api/stats/clients?period=",
	types: "/api/stats/types?period=",
	routes: "/api/stats/routes?period=",
};

let bounds: Record<OverviewEndpoint, Bounds>;
let failing: Set<OverviewEndpoint>;
let calls: Record<OverviewEndpoint, number>;
/** Endpoints that answer for the page's window from their second call onward. */
let catchUp: Set<OverviewEndpoint>;
/** Held to keep one answer in flight while the test moves the page on. */
let hold: { promise: Promise<void>; release: () => void } | null;

function endpointOf(url: string): OverviewEndpoint | null {
	// Longest prefix first: `/api/stats?` and `/api/stats/…` share a stem.
	for (const endpoint of ["timeseries", "clients", "types", "routes", "totals"] as const) {
		if (url.startsWith(PATHS[endpoint])) return endpoint;
	}
	return null;
}

function body(endpoint: OverviewEndpoint, period: Period): unknown {
	const { until, availableSince } = bounds[endpoint];
	const shared = { period, since: SINCE, until, coverage: { ...COVERAGE, available_since: availableSince } };
	switch (endpoint) {
		case "totals":
			return { ...shared, queries: 10, blocked: 2, clients: 1, avg_response_time_us: 1000 };
		case "timeseries":
			return { ...shared, bucket_seconds: 3600, buckets: [] };
		case "clients":
			return { ...shared, bucket_seconds: 3600, clients: [], other: [] };
		case "types":
			return { ...shared, types: [] };
		case "routes":
			return { ...shared, routes: [] };
	}
}

function json(payload: unknown, status = 200): Response {
	return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json" } });
}

beforeEach(() => {
	bounds = {
		totals: { until: UNTIL, availableSince: SINCE },
		timeseries: { until: UNTIL, availableSince: SINCE },
		clients: { until: UNTIL, availableSince: SINCE },
		types: { until: UNTIL, availableSince: SINCE },
		routes: { until: UNTIL, availableSince: SINCE },
	};
	failing = new Set();
	catchUp = new Set();
	hold = null;
	calls = { totals: 0, timeseries: 0, clients: 0, types: 0, routes: 0 };
	vi.stubGlobal(
		"fetch",
		vi.fn(async (input: RequestInfo | URL) => {
			const url = String(input);
			const endpoint = endpointOf(url);
			if (endpoint === null) return json({ error: "not stubbed" }, 404);
			calls[endpoint] += 1;
			if (failing.has(endpoint)) return json({ error: "endpoint unavailable" }, 400);
			if (catchUp.has(endpoint) && calls[endpoint] >= 2)
				bounds[endpoint] = { until: UNTIL, availableSince: SINCE };
			const period = (new URLSearchParams(url.split("?")[1]).get("period") ?? "24h") as Period;
			// Built before the wait, so a held answer carries what its own request
			// would have returned rather than what the page has moved on to.
			const payload = json(body(endpoint, period));
			if (hold !== null && endpoint === "routes" && calls.routes === 2) await hold.promise;
			return payload;
		}),
	);
});

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

function Probe({ period }: { period: Period }) {
	const overview = useOverviewWindow(period);
	return (
		<ul>
			{OVERVIEW_ENDPOINTS.map((endpoint) => {
				const panel = overview[endpoint];
				const detail =
					panel.status === "ready"
						? `${panel.data.period}@${panel.data.until}/${panel.data.coverage.available_since}`
						: panel.status === "error"
							? (panel.error as Error).message
							: "";
				return <li key={endpoint}>{`${endpoint}:${panel.status}:${detail}`}</li>;
			})}
		</ul>
	);
}

function renderProbe(period: Period = "24h") {
	const client = createQueryClient();
	const view = render(
		<QueryClientProvider client={client}>
			<Probe period={period} />
		</QueryClientProvider>,
	);
	return {
		rerenderWith: (next: Period) =>
			view.rerender(
				<QueryClientProvider client={client}>
					<Probe period={next} />
				</QueryClientProvider>,
			),
	};
}

function line(endpoint: OverviewEndpoint): string {
	const item = screen.getAllByRole("listitem").find((element) => element.textContent?.startsWith(`${endpoint}:`));
	if (item === undefined) throw new Error(`no probe line for ${endpoint}`);
	return item.textContent ?? "";
}

test("the window identity is the period, both bounds and the watermark together", () => {
	const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
	expect(sameWindow(base, { ...base })).toBe(true);
	expect(sameWindow(base, { ...base, period: "1h" })).toBe(false);
	expect(sameWindow(base, { ...base, since: SINCE - 1 })).toBe(false);
	expect(sameWindow(base, { ...base, until: UNTIL + 1 })).toBe(false);
	// The bounds agree and the answers still describe different windows: a prune
	// between the two requests moved what the same span can be answered for.
	expect(sameWindow(base, { ...base, availableSince: SINCE + 60 })).toBe(false);
});

test("the newer until wins, and for equal bounds the later watermark does", () => {
	const base = { period: "24h" as const, since: SINCE, until: UNTIL, availableSince: SINCE };
	expect(newerWindow(base, { ...base, until: UNTIL + 60 }).until).toBe(UNTIL + 60);
	expect(newerWindow({ ...base, until: UNTIL + 60 }, base).until).toBe(UNTIL + 60);
	expect(newerWindow(base, { ...base, availableSince: SINCE + 60 }).availableSince).toBe(SINCE + 60);
	// A newer watermark does not outrank an older window's later bound.
	expect(newerWindow({ ...base, until: UNTIL + 60 }, { ...base, availableSince: SINCE + 60 }).until).toBe(UNTIL + 60);
});

test("windowIdOf reads the four fields off any of the five bodies", () => {
	expect(windowIdOf({ period: "7d", since: 1, until: 2, coverage: { complete: false, available_since: 3 } })).toEqual(
		{
			period: "7d",
			since: 1,
			until: 2,
			availableSince: 3,
		},
	);
});

test("five responses for one window render as five ready panels", async () => {
	renderProbe();
	await waitFor(() => expect(line("totals")).toContain("ready"));
	for (const endpoint of OVERVIEW_ENDPOINTS) {
		expect(line(endpoint)).toBe(`${endpoint}:ready:24h@${UNTIL}/${SINCE}`);
	}
});

test("one endpoint behind a bucket boundary is refetched once and then agrees", async () => {
	// Behind on its first answer, caught up by the time the hook asks again.
	bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
	catchUp.add("routes");
	renderProbe();

	await waitFor(() => expect(line("routes")).toContain("ready"));
	expect(calls.routes).toBe(2);
	expect(calls.totals).toBe(1);
});

test("a laggard that stays behind fails its own panel and leaves the rest rendering", async () => {
	bounds.types = { until: UNTIL - 3600, availableSince: SINCE };
	renderProbe();

	await waitFor(() => expect(line("types")).toContain("error"));
	expect(line("types")).toContain("different window");
	// One retry, not a loop.
	expect(calls.types).toBe(2);
	for (const endpoint of ["totals", "timeseries", "clients", "routes"] as const) {
		expect(line(endpoint)).toContain("ready");
	}
});

test("a failed request degrades its own panel; the charts keep the window", async () => {
	failing.add("routes");
	renderProbe();

	await waitFor(() => expect(line("routes")).toContain("error"));
	expect(line("routes")).toContain("endpoint unavailable");
	expect(line("timeseries")).toContain("ready");
	expect(line("totals")).toContain("ready");
});

test("a watermark that advanced mid-page is a mismatch, not a mixed window", async () => {
	// Same bounds, later watermark: retention pruned between the two responses.
	bounds.clients = { until: UNTIL, availableSince: SINCE + 600 };
	renderProbe();

	await waitFor(() => expect(line("clients")).toContain(`/${SINCE + 600}`));
	// The page adopts the later watermark, so the four older answers are the
	// laggards and each gets its one retry rather than rendering beside it.
	await waitFor(() => expect(calls.totals).toBe(2));
	expect(line("clients")).toContain("ready");
});

test("a retained previous-period body never renders under the new period's label", async () => {
	const { rerenderWith } = renderProbe("24h");
	await waitFor(() => expect(line("totals")).toBe(`totals:ready:24h@${UNTIL}/${SINCE}`));

	rerenderWith("1h");
	// Whatever `keepPreviousData` is holding, no panel may claim it answers 1h.
	await waitFor(() => expect(line("totals")).toBe(`totals:ready:1h@${UNTIL}/${SINCE}`));
	for (const endpoint of OVERVIEW_ENDPOINTS) expect(line(endpoint)).toContain("1h@");
});

test("a period change buys the new window its own retry", async () => {
	bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
	const { rerenderWith } = renderProbe("24h");
	await waitFor(() => expect(line("routes")).toContain("error"));
	const spent = calls.routes;

	rerenderWith("1h");
	// The mismatch persists under the new period, and the episode key changed
	// with it: the retry the abandoned period spent is not the new one's.
	await waitFor(() => expect(calls.routes).toBeGreaterThan(spent));
	await waitFor(() => expect(line("routes")).toContain("error"));
});

test("a retry in flight when the period changes cannot spend the window's retry later", async () => {
	// The stale completion the tokens exist to orphan: routes lags under 24h, the
	// hook issues its one retry, and the reader picks 1h before that retry lands.
	bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
	let release = () => {};
	hold = { promise: new Promise<void>((resolve) => (release = resolve)), release: () => release() };

	const { rerenderWith } = renderProbe("24h");
	await waitFor(() => expect(calls.routes).toBe(2));

	bounds.routes = { until: UNTIL, availableSince: SINCE };
	rerenderWith("1h");
	await waitFor(() => expect(line("routes")).toContain("1h@"));

	// The abandoned retry lands now, under a period it was never asked for.
	hold.release();
	hold = null;
	await waitFor(() => expect(line("routes")).toContain("ready"));

	// Back to the window it was issued for, still lagging. The stale completion
	// must not have marked this episode spent: the panel gets a real retry before
	// it is allowed to reach the terminal error.
	bounds.routes = { until: UNTIL - 3600, availableSince: SINCE };
	rerenderWith("24h");

	// The cached lagging body is there to render immediately, and the panel must
	// not state the terminal error off it: that error means "retried and still
	// behind", and this visit has not retried anything yet. An abandoned
	// completion recording the episode as spent is what would produce it here.
	expect(line("routes")).toContain("loading");
	await waitFor(() => expect(line("routes")).toContain("different window"));
});
