import { cleanup, 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 type { QueryDetail } from "@/lib/types";
import { provenance } from "@/features/provenance/provenanceFixture";
import { health } from "@/lib/healthFixture";

function detail(id: number, sections: Parameters<typeof provenance>[0] = {}): QueryDetail {
	return { id, ...provenance(sections) };
}

let responses: Record<string, unknown>;

beforeEach(() => {
	responses = {
		"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
		"/api/health": health(),
	};
	vi.stubGlobal(
		"fetch",
		vi.fn(async (input: RequestInfo | URL) => {
			const payload = responses[String(input)];
			if (payload === undefined) {
				return new Response(JSON.stringify({ error: "no such query" }), {
					status: 404,
					headers: { "content-type": "application/json" },
				});
			}
			return new Response(JSON.stringify(payload), {
				status: 200,
				headers: { "content-type": "application/json" },
			});
		}),
	);
});

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

function renderDetail(id: number, search = "") {
	const queryClient = createQueryClient();
	const router = createAppRouter(
		createMemoryHistory({ initialEntries: [`/activity/queries/${id}${search}`] }),
		queryClient,
	);
	render(
		<AuthProvider>
			<QueryClientProvider client={queryClient}>
				<RouterProvider router={router} />
			</QueryClientProvider>
		</AuthProvider>,
	);
	return router;
}

/** The search parameters a link carries, so an assertion states them by name. */
function hrefSearch(link: HTMLElement): Record<string, string> {
	const query = link.getAttribute("href")?.split("?")[1] ?? "";
	return Object.fromEntries(new URLSearchParams(query));
}

/** The value beside a term, so a section's facts are read as pairs. */
function factValue(label: string): string {
	const term = screen.getByText(label);
	const value = term.nextElementSibling;
	return value?.textContent ?? "";
}

/** The line under the domain, which is what a page is read as at a glance. */
function subtitle(domain: string): string {
	const heading = screen.getByRole("heading", { name: domain });
	return heading.nextElementSibling?.textContent ?? "";
}

test("a blocked query explains itself in the six sections, in pipeline order", async () => {
	responses["/api/queries/42"] = detail(42, {
		request: { time: 1_700_000_000, domain: "ads.example", client: "192.0.2.11", qtype: 28, qclass: 1 },
		group: { id: 3, name: "kids" },
		policy: {
			action: "block",
			reason: "blocklist_wildcard",
			matched: "||tracker.example^",
			source_id: 5,
			source_name: "StevenBlack",
		},
		rewrites: { cname_target: "cdn.tracker.example" },
		route: { kind: "blocked", upstream: "" },
		response: { rcode: 0, duration_us: 1234 },
	});
	renderDetail(42);

	await screen.findByRole("heading", { name: "ads.example" });

	const headings = screen.getAllByRole("heading", { level: 2 }).map((node) => node.textContent);
	expect(headings).toEqual(["Request", "Group", "Policy", "Rewrites", "Route", "Response", "Related"]);

	expect(factValue("Client")).toBe("192.0.2.11");
	expect(factValue("Type")).toBe("AAAA");
	expect(factValue("Class")).toBe("IN (1)");
	expect(factValue("Name")).toBe("kids");
	expect(factValue("Id")).toBe("3");
	expect(factValue("Decision")).toBe("Blocked");
	expect(factValue("Reason")).toBe("Blocklist (wildcard)");
	expect(factValue("Matched")).toBe("||tracker.example^");
	expect(factValue("Blocklist")).toBe("StevenBlack (#5)");
	expect(factValue("CNAME target")).toBe("cdn.tracker.example");
	expect(factValue("Answered by")).toBe("Blocked locally");
	expect(factValue("Upstream")).toBe("No upstream exchange");
	expect(factValue("Result")).toBe("NOERROR (0)");
	expect(factValue("Took")).toBe("1.2 ms");
	// NOERROR is the ordinary case and adds nothing to the verdict.
	expect(subtitle("ads.example")).toMatch(/ — Blocked$/);
});

test("an upstream SERVFAIL names the resolver that failed and the code the client saw", async () => {
	responses["/api/queries/7"] = detail(7, {
		request: { domain: "news.example" },
		route: { kind: "upstream", upstream: "https://dns.example/dns-query" },
		response: { rcode: 2, duration_us: null },
	});
	renderDetail(7);

	await screen.findByRole("heading", { name: "news.example" });
	expect(factValue("Answered by")).toBe("Upstream resolver");
	expect(factValue("Upstream")).toBe("https://dns.example/dns-query");
	expect(factValue("Result")).toBe("SERVFAIL (2)");
	expect(factValue("Took")).toBe("Not measured");
	// The policy allowed the query; the client still got nothing, and the
	// headline has to say so rather than reading as a success.
	expect(subtitle("news.example")).toMatch(/ — Allowed — SERVFAIL \(2\)$/);
});

test("a forward-zone answer says the matcher never ran, not that nothing matched", async () => {
	responses["/api/queries/14"] = detail(14, {
		request: { domain: "nas.lan.home" },
		policy: { action: "allow", reason: "forward_zone", matched: "" },
		route: { kind: "forward_zone", forward_zone: "lan.home", upstream: "udp://192.168.1.1:53" },
	});
	renderDetail(14);

	await screen.findByRole("heading", { name: "nas.lan.home" });
	expect(factValue("Reason")).toBe("Forward zone");
	expect(factValue("Matched")).toBe("The matcher never ran");
});

test("a query the matcher did evaluate keeps the honest empty verdict", async () => {
	responses["/api/queries/15"] = detail(15, {
		policy: { action: "allow", reason: "no_match", matched: "" },
	});
	renderDetail(15);

	await screen.findByRole("heading", { name: "example.com" });
	expect(factValue("Reason")).toBe("No match");
	expect(factValue("Matched")).toBe("Nothing matched");
});

test("empty text fields read as absent facts, never as blank values", async () => {
	responses["/api/queries/8"] = detail(8, {
		group: { id: null, name: "" },
		policy: { action: "not_evaluated", reason: "paused", matched: "", source_id: null, source_name: "" },
		route: { kind: "upstream", forward_zone: "", upstream: "udp://9.9.9.9:53" },
	});
	renderDetail(8);

	await screen.findByRole("heading", { name: "example.com" });
	expect(factValue("Name")).toBe("No group recorded");
	expect(factValue("Matched")).toBe("The matcher never ran");
	expect(factValue("Blocklist")).toBe("Not a blocklist decision");
	expect(factValue("Safe search")).toBe("No rewrite");
	expect(factValue("Reason")).toBe("Filtering paused");
});

test("a log with hidden domains renders the server's marker, with nothing invented around it", async () => {
	responses["/api/queries/9"] = detail(9, {
		request: { domain: "hidden" },
		policy: { action: "block", reason: "blocklist_domain", matched: "hidden", source_name: "StevenBlack" },
		rewrites: { cname_target: "hidden", safe_search_target: "hidden" },
		route: { kind: "blocked", upstream: "" },
	});
	renderDetail(9);

	await screen.findByRole("heading", { name: "hidden" });
	expect(factValue("Domain")).toBe("hidden");
	expect(factValue("Matched")).toBe("hidden");
	expect(factValue("CNAME target")).toBe("hidden");
	expect(factValue("Safe search")).toBe("hidden");
	// The client is governed by its own flag and stays visible here.
	expect(factValue("Client")).toBe("192.0.2.10");
});

test("the related actions carry absolute bounds around the query, and the domain into the simulation", async () => {
	responses["/api/queries/11"] = detail(11, {
		request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
	});
	renderDetail(11);

	await screen.findByRole("heading", { name: "shop.example" });
	const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
	expect(
		within(related)
			.getByRole("link", { name: /Test this domain/ })
			.getAttribute("href"),
	).toBe("/activity/test?domain=shop.example");
	// No origin bound at all: five minutes either side of the query itself.
	expect(hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }))).toEqual({
		mode: "history",
		domain: "shop.example",
		since: "1699999700",
		until: "1700000300",
	});
	expect(hrefSearch(within(related).getByRole("link", { name: "All activity from this client" }))).toEqual({
		mode: "history",
		client: "192.0.2.12",
		since: "1699999700",
		until: "1700000300",
	});
	// The diagnostics window is the query's own moment, never the origin's.
	expect(hrefSearch(within(related).getByRole("link", { name: /Diagnostics around/ }))).toEqual({
		since: "1699999700",
		until: "1700000300",
	});
});

test("an origin bound wins over the default window, one bound at a time", async () => {
	responses["/api/queries/17"] = detail(17, {
		request: { time: 1_700_000_000, domain: "shop.example", client: "192.0.2.12" },
	});
	renderDetail(17, "?mode=history&since=1600000000");

	await screen.findByRole("heading", { name: "shop.example" });
	const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
	const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
	expect(link["since"]).toBe("1600000000");
	expect(link["until"]).toBe("1700000300");
});

test("both origin bounds carry through untouched", async () => {
	responses["/api/queries/18"] = detail(18, { request: { time: 1_700_000_000, domain: "shop.example" } });
	renderDetail(18, "?mode=history&since=1600000000&until=1600000060");

	await screen.findByRole("heading", { name: "shop.example" });
	const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
	const link = hrefSearch(within(related).getByRole("link", { name: "All activity for this domain" }));
	expect(link["since"]).toBe("1600000000");
	expect(link["until"]).toBe("1600000060");
});

test("the back link restores the investigation the reader came from", async () => {
	responses["/api/queries/19"] = detail(19, { request: { domain: "shop.example" } });
	renderDetail(19, "?mode=history&domain=shop&since=1600000000&blocked=true");

	await screen.findByRole("heading", { name: "shop.example" });
	expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
		mode: "history",
		domain: "shop",
		since: "1600000000",
		blocked: "true",
	});
});

function clientList(client: { ip: string; name: string; learned_name: string }) {
	return {
		clients: [
			{
				id: 1,
				group_id: 1,
				group: "default",
				hand_edited: client.name !== "",
				first_seen: 1_700_000_000,
				last_seen: 1_700_000_100,
				...client,
			},
		],
	};
}

/** The related section, whose text is read whole because it is prose, not facts. */
async function relatedText(expected: string) {
	const related = screen.getByRole("heading", { name: "Related" }).parentElement!;
	await waitFor(() => expect(related.textContent?.replace(/\s+/g, " ")).toContain(expected));
}

test("the record keeps the address the query came from, and Related carries the name it has now", async () => {
	responses["/api/queries/12"] = detail(12, { request: { domain: "shop.example", client: "192.0.2.12" } });
	responses["/api/clients"] = clientList({ ip: "192.0.2.12", name: "Kids iPad", learned_name: "ipad.lan" });
	renderDetail(12);

	await screen.findByRole("heading", { name: "shop.example" });
	await relatedText("The client list currently names 192.0.2.12 “Kids iPad”.");
	expect(factValue("Client")).toBe("192.0.2.12");
});

test("a learned name is told as the reverse-DNS lookup it is, never as a recorded fact", async () => {
	responses["/api/queries/13"] = detail(13, { request: { client: "192.0.2.13" } });
	responses["/api/clients"] = clientList({ ip: "192.0.2.13", name: "", learned_name: "printer.lan" });
	renderDetail(13);

	await screen.findByRole("heading", { name: "example.com" });
	await relatedText("Reverse DNS currently resolves 192.0.2.13 to printer.lan.");
	expect(factValue("Client")).toBe("192.0.2.13");
});

test("a row retention has pruned explains the 404 and keeps the way back to the log", async () => {
	renderDetail(404, "?mode=history&domain=gone");

	await screen.findByRole("alert");
	expect(screen.getByText(/no such query/)).toBeTruthy();
	expect(hrefSearch(screen.getByRole("link", { name: "← Activity" }))).toEqual({
		mode: "history",
		domain: "gone",
	});
});

/**
 * The related-actions region of a query detail. Scoped on purpose: the sidebar
 * carries a Pause of its own, and this is the one that answers "this query was
 * blocked and should not have been".
 */
function related(): HTMLElement {
	return screen.getByRole("region", { name: "Related" });
}

test("a blocked query's Related offers Pause; an allowed one has nothing to pause about", async () => {
	responses["/api/queries/50"] = detail(50, {
		policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
		route: { kind: "blocked", upstream: "" },
	});
	renderDetail(50);

	await screen.findByRole("heading", { name: "example.com" });
	await waitFor(() => expect(within(related()).getByRole("button", { name: "Pause" })).toBeTruthy());

	cleanup();
	responses["/api/queries/51"] = detail(51, { policy: { action: "allow", reason: "no_match", matched: "" } });
	renderDetail(51);

	await screen.findByRole("heading", { name: "example.com" });
	expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});

test("the Pause action stays away while protection is unavailable", async () => {
	responses["/api/health"] = health({ protection: { state: "unavailable", until: null } });
	responses["/api/queries/52"] = detail(52, {
		policy: { action: "block", reason: "blocklist_domain", matched: "ads.example" },
		route: { kind: "blocked", upstream: "" },
	});
	renderDetail(52);

	await screen.findByRole("heading", { name: "example.com" });
	await waitFor(() => expect(screen.getByText("Diagnostics around this query")).toBeTruthy());
	expect(within(related()).queryByRole("button", { name: "Pause" })).toBeNull();
});
