import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { health } from "@/lib/healthFixture";
import type { DiagnosticEvent, DiagnosticsPage } from "@/lib/types";

// Ages are rendered against the wall clock, so the fixtures are anchored to it
// rather than to a frozen instant: faking time here would fight the query
// client's own timers for no gain.
const NOW_S = Math.floor(Date.now() / 1000);

function event(id: number, overrides: Partial<DiagnosticEvent> = {}): DiagnosticEvent {
	return {
		id,
		code: "blocklist.refresh",
		component: "blocklist",
		subject: "StevenBlack",
		severity: "warning",
		first_seen: NOW_S - 3600,
		last_seen: NOW_S - 300,
		occurrences: 3,
		resolved_at: null,
		detail: "download failed: ConnectionTimedOut",
		...overrides,
	};
}

function page(events: DiagnosticEvent[], nextBefore: number | null = null): DiagnosticsPage {
	return { events, next_before: nextBefore, active: { warnings: 1, errors: 1 } };
}

const ACTIVE = page([
	event(42),
	event(41, {
		code: "upstream.exchange",
		component: "upstream",
		subject: "tls://dns.example:853",
		severity: "error",
		occurrences: 1,
	}),
]);

const RESOLVED = page([
	event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 }),
]);

/** A stubbed response that carries a non-200 status instead of a payload. */
class Failure {
	constructor(
		readonly status: number,
		readonly body: unknown,
	) {}
}

function fail(status: number, message: string): Failure {
	return new Failure(status, { error: message });
}

/** A 204: what `DELETE /api/diagnostics/{id}` answers on a purge. */
const NO_CONTENT = Symbol("204");

let responses: Record<string, unknown>;
let requested: string[];

beforeEach(() => {
	requested = [];
	responses = {
		"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
		// The health strip at the top of the page; quiet on a healthy box, which is
		// what every test below wants it to be.
		"/api/health": health(),
		"/api/diagnostics?state=active": ACTIVE,
		"/api/diagnostics?state=resolved": RESOLVED,
	};
	vi.stubGlobal(
		"fetch",
		vi.fn(async (input: RequestInfo | URL, init?: { method?: string }) => {
			const url = String(input);
			const method = init?.method ?? "GET";
			// Reads stay keyed by url alone, so the assertions below read as the
			// request line they are; writes carry their method.
			const key = method === "GET" ? url : `${method} ${url}`;
			requested.push(key);
			const payload = responses[key];
			if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
			if (payload === NO_CONTENT) return new Response(null, { status: 204 });
			if (payload instanceof Failure) {
				return new Response(JSON.stringify(payload.body), {
					status: payload.status,
					headers: { "content-type": "application/json" },
				});
			}
			return new Response(JSON.stringify(payload), {
				status: 200,
				headers: { "content-type": "application/json" },
			});
		}),
	);
});

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

/**
 * `retry` is off in the failure tests: the shared client backs 5xx off for
 * seconds, which the render assertions would sit through for nothing.
 */
function renderRoute(path = "/diagnostics", { retry = true } = {}) {
	const queryClient = createQueryClient();
	if (!retry) {
		const defaults = queryClient.getDefaultOptions();
		queryClient.setDefaultOptions({ ...defaults, queries: { ...defaults.queries, retry: false } });
	}
	const router = createAppRouter(createMemoryHistory({ initialEntries: [path] }), queryClient);
	render(
		<AuthProvider>
			<QueryClientProvider client={queryClient}>
				<RouterProvider router={router} />
			</QueryClientProvider>
		</AuthProvider>,
	);
	return router;
}

/** A RAC Select names its trigger with the current value and then the label. */
function trigger(label: string): HTMLElement {
	return screen.getByRole("button", { name: new RegExp(`${label}$`) });
}

async function pick(label: string, option: string) {
	fireEvent.click(trigger(label));
	fireEvent.click(await screen.findByRole("option", { name: option }));
	await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
}

test("active episodes come first, each with its title, subject, age and count", async () => {
	renderRoute();
	await screen.findByRole("heading", { name: "Diagnostics" });

	const active = screen.getByText("Blocklist source failed to update").closest("li")!;
	expect(within(active).getByText("Warning")).toBeTruthy();
	expect(within(active).getByText("StevenBlack")).toBeTruthy();
	expect(within(active).getByText(/Active for 1h · 3 occurrences/)).toBeTruthy();

	const failing = screen.getByText("Upstream failing").closest("li")!;
	expect(within(failing).getByText("Error")).toBeTruthy();
	expect(within(failing).getByText(/1 occurrence(?!s)/)).toBeTruthy();

	// The resolved history is a separate section, below the active list.
	const table = within(screen.getByRole("table"));
	expect(table.getByText("Disk space low")).toBeTruthy();
	expect(screen.getByText(/Showing 1 resolved entry — end of history/)).toBeTruthy();
});

test("nothing open reads as good news, not as a broken page", async () => {
	responses["/api/diagnostics?state=active"] = page([]);
	renderRoute();
	await screen.findByRole("heading", { name: "Diagnostics" });

	const healthy = await screen.findByText("No active operational issues.");
	expect(healthy.getAttribute("role")).toBe("status");
	// Quiet: no alert anywhere on the page, and no empty table standing in.
	expect(screen.queryByRole("alert")).toBeNull();
});

test("a filter lands in the url and refetches both sections through it", async () => {
	responses["/api/diagnostics?severity=error&state=active"] = page([
		event(41, { code: "upstream.exchange", component: "upstream", severity: "error" }),
	]);
	responses["/api/diagnostics?severity=error&state=resolved"] = page([]);
	const router = renderRoute();
	await screen.findByRole("heading", { name: "Diagnostics" });

	await pick("Severity", "Errors");

	await waitFor(() => expect(router.state.location.search).toEqual({ severity: "error" }));
	await waitFor(() => expect(screen.queryByText("Blocklist source failed to update")).toBeNull());
	expect(requested).toContain("/api/diagnostics?severity=error&state=active");
	expect(requested).toContain("/api/diagnostics?severity=error&state=resolved");
});

test("the state filter hides the section it excludes", async () => {
	const router = renderRoute();
	await screen.findByRole("heading", { name: "Diagnostics" });

	await pick("Show", "Active only");

	await waitFor(() => expect(router.state.location.search).toEqual({ state: "active" }));
	expect(screen.queryByRole("heading", { name: "Resolved" })).toBeNull();
	expect(screen.getByRole("heading", { name: "Active" })).toBeTruthy();
});

test("a url written by hand starts on the filters it names", async () => {
	responses["/api/diagnostics?component=disk&state=resolved"] = RESOLVED;
	renderRoute("/diagnostics?state=resolved&component=disk");
	await screen.findByRole("heading", { name: "Diagnostics" });

	await screen.findByText("Disk space low");
	expect(screen.queryByRole("heading", { name: "Active" })).toBeNull();
	expect(requested).toContain("/api/diagnostics?component=disk&state=resolved");
});

test("load more appends the next page of resolved history", async () => {
	responses["/api/diagnostics?state=resolved"] = page(
		[event(30, { code: "disk.space", component: "disk", subject: "data", resolved_at: NOW_S - 7200 })],
		30,
	);
	responses["/api/diagnostics?state=resolved&before=30"] = page([
		event(12, {
			code: "certificate.reload",
			component: "certificate",
			subject: "doh",
			resolved_at: NOW_S - 90_000,
		}),
	]);
	renderRoute();
	await screen.findByText("Disk space low");

	fireEvent.click(screen.getByRole("button", { name: "Load more" }));

	await screen.findByText("TLS certificate reload failed");
	expect(screen.getByText(/Showing 2 resolved entries — end of history/)).toBeTruthy();
});

test("an unavailable store reports the failure instead of loading forever", async () => {
	responses["/api/diagnostics?state=active"] = fail(503, "store unavailable");
	renderRoute("/diagnostics", { retry: false });
	await screen.findByRole("heading", { name: "Diagnostics" });

	const alert = await screen.findByRole("alert");
	expect(alert.textContent).toContain("The server is starting or degraded.");
	expect(screen.queryByText("Loading diagnostics…")).toBeNull();
	// The resolved section answered, so it still renders its own history.
	expect(screen.getByText("Disk space low")).toBeTruthy();
});

test("a failed history query reports the failure and retries on demand", async () => {
	responses["/api/diagnostics?state=resolved"] = fail(500, "diagnostics store read failed");
	renderRoute("/diagnostics", { retry: false });
	await screen.findByRole("heading", { name: "Diagnostics" });

	const alert = await screen.findByRole("alert");
	expect(alert.textContent).toContain("diagnostics store read failed");
	expect(screen.queryByText("Loading history…")).toBeNull();

	responses["/api/diagnostics?state=resolved"] = RESOLVED;
	fireEvent.click(screen.getByRole("button", { name: "Retry" }));

	await screen.findByText("Disk space low");
	expect(screen.queryByRole("alert")).toBeNull();
});

test("only the resolved history offers a purge", async () => {
	renderRoute();
	await screen.findByRole("heading", { name: "Diagnostics" });

	// An episode still failing is the state of the box, not history: no purge
	// affordance anywhere on its card.
	const active = (await screen.findByText("Blocklist source failed to update")).closest("li")!;
	expect(within(active).queryByRole("button", { name: "Purge" })).toBeNull();

	const row = screen.getByText("Disk space low").closest("tr")!;
	expect(within(row).getByRole("button", { name: "Purge" })).toBeTruthy();
	expect(screen.getByRole("button", { name: "Purge all resolved" })).toBeTruthy();
});

test("with no resolved history there is nothing to purge in bulk", async () => {
	responses["/api/diagnostics?state=resolved"] = page([]);
	renderRoute();
	await screen.findByRole("heading", { name: "Diagnostics" });

	await screen.findByText("Nothing has failed and recovered in the retained window.");
	expect(screen.queryByRole("button", { name: "Purge all resolved" })).toBeNull();
});

test("purging one row asks first, then sends the DELETE and refetches the lists", async () => {
	responses["DELETE /api/diagnostics/30"] = NO_CONTENT;
	renderRoute();
	await screen.findByText("Disk space low");

	fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));

	const dialog = await screen.findByRole("alertdialog");
	expect(dialog.textContent).toContain("Purge this resolved event? Its history is gone for good.");
	fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
	expect(requested).not.toContain("DELETE /api/diagnostics/30");

	fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
	fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));

	await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics/30"));
	// The invalidation covers both sections: the page the row left and the
	// active list, whose `active` counts come from the same table.
	await waitFor(() =>
		expect(requested.filter((url) => url === "/api/diagnostics?state=resolved").length).toBeGreaterThan(1),
	);
	await waitFor(() =>
		expect(requested.filter((url) => url === "/api/diagnostics?state=active").length).toBeGreaterThan(1),
	);
});

test("purging the whole history asks first and sends one DELETE", async () => {
	responses["DELETE /api/diagnostics"] = { purged: 1 };
	renderRoute();
	await screen.findByText("Disk space low");

	fireEvent.click(screen.getByRole("button", { name: "Purge all resolved" }));
	const dialog = await screen.findByRole("alertdialog");
	expect(dialog.textContent).toContain("Purge all resolved events? Active events are kept.");

	// What the server will answer once the purge has landed; the refetch the
	// mutation triggers is what has to pick it up.
	responses["/api/diagnostics?state=resolved"] = page([]);
	fireEvent.click(within(dialog).getByRole("button", { name: "Purge all" }));

	await waitFor(() => expect(requested).toContain("DELETE /api/diagnostics"));
	await waitFor(() => expect(screen.queryByText("Disk space low")).toBeNull());
	expect(screen.getByText("Blocklist source failed to update")).toBeTruthy();
});

test("a refused purge reports the server's reason and keeps the row", async () => {
	responses["DELETE /api/diagnostics/30"] = fail(409, "the event is still active; it can be purged once it resolves");
	renderRoute("/diagnostics", { retry: false });
	await screen.findByText("Disk space low");

	fireEvent.click(within(screen.getByText("Disk space low").closest("tr")!).getByRole("button", { name: "Purge" }));
	fireEvent.click(within(await screen.findByRole("alertdialog")).getByRole("button", { name: "Purge" }));

	const alert = await screen.findByRole("alert");
	expect(alert.textContent).toContain("the event is still active");
	expect(screen.getByText("Disk space low")).toBeTruthy();
});

test("an episode links to its own detail page", async () => {
	responses["/api/diagnostics/42"] = event(42);
	renderRoute();
	const link = await screen.findByRole("link", { name: "Blocklist source failed to update" });
	expect(link.getAttribute("href")).toBe("/diagnostics/42");
});

test("an absolute window reaches both requests and is stated on the page", async () => {
	const bounded = "since=1699999700&until=1700000300";
	responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
	responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
	renderRoute(`/diagnostics?${bounded}`);

	await screen.findByRole("heading", { name: "Active" });
	expect(requested).toContain(`/api/diagnostics?${bounded}&state=active`);
	expect(requested).toContain(`/api/diagnostics?${bounded}&state=resolved`);
	// An empty section inside a five-minute window means something different
	// from an empty section over the whole history, so the page has to say so.
	expect(screen.getByText(/Showing events that overlap/)).toBeTruthy();
});

test("clearing the range drops both bounds from the url", async () => {
	const bounded = "since=1699999700&until=1700000300";
	responses[`/api/diagnostics?${bounded}&state=active`] = ACTIVE;
	responses[`/api/diagnostics?${bounded}&state=resolved`] = RESOLVED;
	const router = renderRoute(`/diagnostics?${bounded}`);

	fireEvent.click(await screen.findByRole("button", { name: "Clear the time range" }));
	await waitFor(() => {
		expect(router.state.location.search).not.toContain("since");
	});
	expect(router.state.location.search).not.toContain("until");
});

test("a bound that is not a whole second is dropped, leaving the page unbounded", async () => {
	renderRoute("/diagnostics?since=1.5&until=Infinity");

	await screen.findByRole("heading", { name: "Active" });
	expect(requested).toContain("/api/diagnostics?state=active");
	expect(screen.queryByText(/Showing events that overlap/)).toBeNull();
});
