/**
 * Activity in history mode, through the real router: the URL is the applied
 * state, so nothing here can be checked by rendering the page on its own.
 */

import { act, 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 { Client, Coverage, QueriesPage, QueryRow } from "@/lib/types";
import { queryRow } from "@/features/provenance/provenanceFixture";

function client(id: number, ip: string, name: string, learnedName: string): Client {
	return {
		id,
		ip,
		name,
		learned_name: learnedName,
		group_id: 1,
		group: "default",
		hand_edited: name !== "",
		first_seen: 1_700_000_000,
		last_seen: 1_700_000_100,
	};
}

const CLIENTS: Client[] = [
	client(1, "192.0.2.10", "Kitchen Pi", "pi.lan"),
	client(2, "192.0.2.11", "", "laptop.lan"),
	client(3, "192.0.2.12", "", ""),
];

function row(id: number, domain: string, overrides: Partial<QueryRow> = {}): QueryRow {
	return queryRow(id, { ts: 1_700_000_000 + id, domain, upstream: "udp://9.9.9.9:53", ...overrides });
}

const COMPLETE: Coverage = { complete: true, available_since: 1_600_000_000 };

/** The blocked row every page fixture reuses. */
const BLOCKED = {
	blocked: true,
	policy_action: "block",
	policy_reason: "blocklist_wildcard",
	route_kind: "blocked",
	upstream: "",
} as const satisfies Partial<QueryRow>;

const PAGES: Record<string, QueriesPage> = {
	"/api/queries": {
		queries: [
			row(20, "first.example", { qtype: 65, cache_hit: true, route_kind: "cache" }),
			row(19, "ads.example", { ...BLOCKED, response_time_us: null, cache_hit: null }),
		],
		next_before: 19,
		coverage: COMPLETE,
	},
	"/api/queries?before=19": {
		queries: [row(5, "older.example")],
		next_before: null,
		coverage: COMPLETE,
	},
	"/api/queries?domain=ads": {
		queries: [row(19, "ads.example", BLOCKED)],
		next_before: null,
		coverage: COMPLETE,
	},
	"/api/queries?domain=ads&blocked=true": {
		queries: [row(19, "ads.example", BLOCKED)],
		next_before: null,
		coverage: COMPLETE,
	},
	"/api/queries?since=1700000000": {
		queries: [row(20, "first.example")],
		next_before: null,
		coverage: COMPLETE,
	},
};

let fetchMock: ReturnType<typeof vi.fn>;

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

const VERSION = { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 };

/** The shell's own requests, which every test serves the same way. */
function stubFetch(handler: (url: string) => Response | Promise<Response>) {
	fetchMock = vi.fn((input: RequestInfo | URL) => {
		const url = String(input);
		if (url === "/api/version") return Promise.resolve(json(VERSION));
		// The shell reads health on every route for the Diagnostics nav badge.
		if (url === "/api/health") return Promise.resolve(json(health()));
		return Promise.resolve(handler(url));
	});
	vi.stubGlobal("fetch", fetchMock);
}

function fromPages(url: string): Response {
	if (url === "/api/clients") return json({ clients: CLIENTS });
	const payload = PAGES[url];
	if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
	return json(payload);
}

beforeEach(() => {
	stubFetch(fromPages);
});

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

function renderPage(path = "/activity") {
	const queryClient = createQueryClient();
	const history = createMemoryHistory({ initialEntries: [path] });
	const router = createAppRouter(history, queryClient);
	render(
		<AuthProvider>
			<QueryClientProvider client={queryClient}>
				<RouterProvider router={router} />
			</QueryClientProvider>
		</AuthProvider>,
	);
	return { queryClient, history };
}

/** Every `/api/queries` URL the run asked for, list pages only. */
function queryCalls(): string[] {
	return fetchMock.mock.calls
		.map((call) => String(call[0]))
		.filter((url) => url === "/api/queries" || url.startsWith("/api/queries?"));
}

test("renders the first page with the seven columns filled in", async () => {
	renderPage();
	await screen.findByText("first.example");

	expect(screen.getAllByRole("columnheader").map((header) => header.textContent)).toEqual([
		"Time",
		"Domain",
		"Client",
		"Type",
		"Result",
		"Route",
		"Duration",
	]);
	const first = screen.getByText("first.example").closest("tr")!;
	expect(within(first).getByText("HTTPS")).toBeTruthy();
	expect(within(first).getByText("NOERROR")).toBeTruthy();
	expect(within(first).getByText("Cache")).toBeTruthy();
	expect(within(first).getByText("1.2 ms")).toBeTruthy();

	const blocked = screen.getByText("ads.example").closest("tr")!;
	// The Result cell says Blocked even though the client saw NOERROR, and the
	// Route cell says how: this is the pair the old Status column could not show.
	expect(within(blocked).getAllByText("Blocked")).toHaveLength(2);
	expect(within(blocked).getByText("—")).toBeTruthy();
	expect(screen.getByText(/Showing 2 queries/)).toBeTruthy();
});

test("resolves each row's client to its display name, keeping the IP as the tooltip", async () => {
	stubFetch((url) => {
		if (url === "/api/clients") return json({ clients: CLIENTS });
		if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
		return json({
			queries: [
				row(20, "named.example", { client_ip: "192.0.2.10" }),
				row(19, "learned.example", { client_ip: "192.0.2.11" }),
				row(18, "nameless.example", { client_ip: "192.0.2.12" }),
				row(17, "stranger.example", { client_ip: "192.0.2.99" }),
			],
			next_before: null,
			coverage: COMPLETE,
		} satisfies QueriesPage);
	});

	renderPage();

	// A hand-typed name wins outright; the learned name never surfaces for it.
	const named = await screen.findByText("Kitchen Pi");
	expect(named.getAttribute("title")).toBe("192.0.2.10");
	expect(screen.queryByText("pi.lan")).toBeNull();

	// A learned name reads muted and nothing more here: the "learned" tag would
	// repeat on every row of the table, so the Clients page carries it instead.
	const learned = screen.getByText("laptop.lan");
	expect(learned.getAttribute("title")).toBe("192.0.2.11");
	expect(within(learned.closest("tr")!).queryByText("learned")).toBeNull();

	// A known client with neither name, and a client the loaded list has never
	// seen, both fall back to the bare address with no tooltip standing in.
	expect(screen.getByText("192.0.2.12").getAttribute("title")).toBeNull();
	expect(screen.getByText("192.0.2.99").getAttribute("title")).toBeNull();
});

test("load more appends the next page and stops at the end of the log", async () => {
	renderPage();
	await screen.findByText("first.example");

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

	expect(screen.getByText("first.example")).toBeTruthy();
	expect(screen.getByText(/Showing 3 queries — end of log/)).toBeTruthy();
	expect(screen.queryByRole("button", { name: "Load more" })).toBeNull();
});

test("applying a filter puts it in the url, refetches, and resets the accumulated list", async () => {
	const { history } = renderPage();
	await screen.findByText("first.example");

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

	fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
	fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));

	await screen.findByText(/Showing 1 query /);
	expect(history.location.search).toContain("domain=ads");
	expect(screen.getByText("ads.example")).toBeTruthy();
	expect(screen.queryByText("first.example")).toBeNull();
	expect(screen.queryByText("older.example")).toBeNull();
});

test("a load-more that resolves after a filter change is discarded", async () => {
	let releaseLoadMore: () => void = () => {};
	stubFetch((url) => {
		if (url === "/api/queries?before=19") {
			return new Promise<Response>((resolve) => {
				releaseLoadMore = () => resolve(json(PAGES["/api/queries?before=19"]));
			});
		}
		return fromPages(url);
	});

	renderPage();
	await screen.findByText("first.example");

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

	fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
	fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
	await screen.findByText(/Showing 1 query /);

	releaseLoadMore();
	await act(async () => {
		await new Promise((resolve) => setTimeout(resolve, 0));
	});

	expect(screen.queryByText("older.example")).toBeNull();
	expect(screen.getByText(/Showing 1 query /)).toBeTruthy();
	expect(screen.queryByRole("alert")).toBeNull();
});

test("load more is disabled while a filter change shows placeholder data, then uses the fresh cursor", async () => {
	let releaseFiltered: () => void = () => {};
	const filteredPage: QueriesPage = {
		queries: [row(19, "ads.example", BLOCKED)],
		next_before: 7,
		coverage: COMPLETE,
	};
	const filteredOlderPage: QueriesPage = {
		queries: [row(3, "ads.older.example")],
		next_before: null,
		coverage: COMPLETE,
	};
	stubFetch((url) => {
		if (url === "/api/queries?domain=ads") {
			return new Promise<Response>((resolve) => {
				releaseFiltered = () => resolve(json(filteredPage));
			});
		}
		if (url === "/api/queries?domain=ads&before=7") return json(filteredOlderPage);
		return fromPages(url);
	});

	renderPage();
	await screen.findByText("first.example");

	fireEvent.change(screen.getByLabelText("Domain contains"), { target: { value: "ads" } });
	fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));

	const staleButton = await screen.findByRole("button", { name: "Load more" });
	expect(staleButton).toHaveProperty("disabled", true);
	fireEvent.click(staleButton);
	expect(queryCalls()).not.toContain("/api/queries?domain=ads&before=19");

	releaseFiltered();
	await waitFor(() => {
		expect(screen.queryByText("first.example")).toBeNull();
	});

	const freshButton = screen.getByRole("button", { name: "Load more" });
	expect(freshButton).toHaveProperty("disabled", false);
	fireEvent.click(freshButton);
	await screen.findByText("ads.older.example");

	expect(queryCalls()).toContain("/api/queries?domain=ads&before=7");
	expect(screen.getByText(/Showing 2 queries — end of log/)).toBeTruthy();
});

test("a background refetch after new rows arrive leaves no gap between the loaded pages", async () => {
	// The newest-100 window moves up while the reader has a second page open.
	// Refetching only the first page would drop n20 and n19 out of the middle
	// of the table; the second page must be replayed from the fresh cursor.
	const before: Record<string, QueriesPage> = {
		"/api/queries": {
			queries: [row(20, "n20.example"), row(19, "n19.example")],
			next_before: 19,
			coverage: COMPLETE,
		},
		"/api/queries?before=19": {
			queries: [row(18, "n18.example"), row(17, "n17.example")],
			next_before: null,
			coverage: COMPLETE,
		},
	};
	const after: Record<string, QueriesPage> = {
		"/api/queries": {
			queries: [row(22, "n22.example"), row(21, "n21.example")],
			next_before: 21,
			coverage: COMPLETE,
		},
		"/api/queries?before=21": {
			queries: [row(20, "n20.example"), row(19, "n19.example"), row(18, "n18.example"), row(17, "n17.example")],
			next_before: null,
			coverage: COMPLETE,
		},
	};
	let live = before;
	stubFetch((url) => {
		const payload = live[url];
		if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
		return json(payload);
	});

	const { queryClient } = renderPage();
	await screen.findByText("n20.example");
	fireEvent.click(screen.getByRole("button", { name: "Load more" }));
	await screen.findByText("n17.example");

	live = after;
	await act(async () => {
		await queryClient.invalidateQueries({ queryKey: ["queries"] });
	});

	await screen.findByText("n22.example");
	const shown = screen.getAllByText(/^n\d+\.example$/).map((cell) => cell.textContent);
	expect(shown).toEqual(["n22.example", "n21.example", "n20.example", "n19.example", "n18.example", "n17.example"]);
	expect(screen.getByText(/Showing 6 queries — end of log/)).toBeTruthy();
});

test("a 401 on load more routes through handleUnauthorized instead of the inline error", async () => {
	const assign = vi.fn();
	vi.stubGlobal("location", { pathname: "/activity", search: "", assign });
	stubFetch((url) => {
		if (url === "/api/queries?before=19") {
			return new Response(JSON.stringify({ error: "unauthorized" }), {
				status: 401,
				headers: { "content-type": "application/json" },
			});
		}
		return fromPages(url);
	});

	renderPage();
	await screen.findByText("first.example");

	fireEvent.click(screen.getByRole("button", { name: "Load more" }));
	await waitFor(() => {
		expect(assign).toHaveBeenCalledWith(`/login?redirect=${encodeURIComponent("/activity")}`);
	});

	expect(screen.queryByRole("alert")).toBeNull();
	expect(screen.queryByText(/Failed to load more/)).toBeNull();
});

test("each row links into its own detail page, carrying the investigation with it", async () => {
	renderPage("/activity?mode=history&since=1700000000");
	await screen.findByText("first.example");

	const link = screen.getByRole("link", { name: "first.example" });
	expect(link.getAttribute("href")).toContain("/activity/queries/20");
	expect(link.getAttribute("href")).toContain("since=1700000000");
	// An <a href> is in the tab order by default; nothing here may opt it out.
	expect(link.getAttribute("tabindex")).toBeNull();
});

test("a pruned window tells the reader when history starts", async () => {
	stubFetch((url) => {
		if (url === "/api/clients") return json({ clients: CLIENTS });
		if (url !== "/api/queries") return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
		return json({
			queries: [row(20, "kept.example")],
			next_before: null,
			coverage: { complete: false, available_since: 1_700_000_000 },
		} satisfies QueriesPage);
	});
	renderPage();

	await screen.findByText("kept.example");
	expect(screen.getByText(/Query history is available from/)).toBeTruthy();
});

test("a complete window shows no coverage notice", async () => {
	renderPage();
	await screen.findByText("first.example");
	expect(screen.queryByText(/Query history is available from/)).toBeNull();
});

test("a ?domain= link seeds the filter form and fetches that domain on arrival", async () => {
	renderPage("/activity?domain=ads");

	await screen.findByText("ads.example");
	expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "ads");
	expect(screen.queryByText("first.example")).toBeNull();
});

test("history forwards exactly the six normalized filter fields and nothing else", async () => {
	stubFetch((url) => {
		if (url === "/api/clients") return json({ clients: CLIENTS });
		return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
	});
	renderPage(
		"/activity?mode=history&domain=%20ads%20&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600&bogus=1&limit=9999",
	);

	await screen.findByText("No queries match the current filters.");
	expect(queryCalls()).toEqual([
		"/api/queries?domain=ads&client=192.0.2.5&blocked=true&since=1700000000&until=1700000600",
	]);
});

test("a rejected search parameter is dropped rather than guessed at", async () => {
	renderPage("/activity?since=1.5&blocked=%22true%22&domain=%20%20");

	await screen.findByText("first.example");
	// Nothing survived validation, so the request is the unfiltered one.
	expect(queryCalls()).toEqual(["/api/queries"]);
	expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
});

test("the form draft follows the url back and forward, seconds included", async () => {
	stubFetch((url) => {
		if (url === "/api/clients") return json({ clients: CLIENTS });
		return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
	});
	// A bound with non-zero seconds: the round trip has to keep them, and the
	// untouched field has to carry the original number rather than re-parse.
	const seeded = 1_700_000_017;
	const { history } = renderPage(`/activity?mode=history&domain=first&since=${seeded}`);

	const domainInput = await screen.findByLabelText("Domain contains");
	expect(domainInput).toHaveProperty("value", "first");
	const sinceInput = screen.getByLabelText("Since") as HTMLInputElement;
	expect(sinceInput.value).toContain(":37");

	fireEvent.change(domainInput, { target: { value: "second" } });
	fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));
	await waitFor(() => expect(history.location.search).toContain("domain=second"));
	// The untouched Since bound applied as the exact second it was seeded with.
	expect(queryCalls()).toContain(`/api/queries?domain=second&since=${seeded}`);

	act(() => history.back());
	await waitFor(() => {
		expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "first");
	});

	act(() => history.forward());
	await waitFor(() => {
		expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "second");
	});
});

/** 2 a.m. on the EU spring-forward date: an hour that exists in some zones and not others. */
const DST_WALL_TIME = "2026-03-29T02:30:00";

/**
 * Whether that wall time names an instant in the timezone the suite runs in.
 * `new Date` slides a spring-forward gap silently forward, so an hour or minute
 * that comes back different from the one written *is* the gap.
 */
function wallTimeExists(text: string): boolean {
	const written = /T(\d{2}):(\d{2})/.exec(text)!;
	const parsed = new Date(text);
	return parsed.getHours() === Number(written[1]) && parsed.getMinutes() === Number(written[2]);
}

test("a wall-clock time the daylight-saving jump skips is refused, not silently moved", async () => {
	// One expected outcome per timezone, decided here rather than accepted from
	// the page: in a zone with the jump the bound must be refused outright, and
	// in a zone without it the same text is an ordinary instant that applies.
	const inGap = !wallTimeExists(DST_WALL_TIME);
	const unix = Math.floor(new Date(DST_WALL_TIME).getTime() / 1000);
	stubFetch((url) => {
		if (url === `/api/queries?since=${unix}`) {
			return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
		}
		return fromPages(url);
	});

	const { history } = renderPage();
	await screen.findByText("first.example");
	const callsBefore = queryCalls().length;
	const searchBefore = history.location.search;

	fireEvent.change(screen.getByLabelText("Since"), { target: { value: DST_WALL_TIME } });
	fireEvent.click(screen.getByRole("button", { name: "Apply filters" }));

	if (inGap) {
		expect(screen.getByRole("alert").textContent).toContain("daylight saving");
		// Refused means refused: no navigation, and no request for the hour the
		// operator did not ask for.
		expect(history.location.search).toBe(searchBefore);
		expect(queryCalls()).toHaveLength(callsBefore);
		return;
	}

	await waitFor(() => expect(history.location.search).toContain(`since=${unix}`));
	expect(queryCalls()).toContain(`/api/queries?since=${unix}`);
	expect(screen.queryByRole("alert")).toBeNull();
});

test("the policy simulation is reachable from the header, with no rows to click through", async () => {
	stubFetch((url) => {
		if (url === "/api/clients") return json({ clients: CLIENTS });
		if (url === "/api/groups") return json({ groups: [{ id: 1, name: "default", safe_search: false }] });
		return json({ queries: [], next_before: null, coverage: COMPLETE } satisfies QueriesPage);
	});

	const { history } = renderPage();
	// An empty log is exactly the case a row-borne link cannot serve.
	await screen.findByText("No queries logged yet.");

	const link = screen.getByRole("link", { name: "Current policy simulation" });
	expect(link.getAttribute("href")).toBe("/activity/test");

	fireEvent.click(link);
	await screen.findByRole("heading", { level: 1, name: "Current policy simulation" });
	expect(history.location.pathname).toBe("/activity/test");
});

test("Clear empties the url as well as the form", async () => {
	const { history } = renderPage("/activity?mode=history&domain=ads&blocked=true");
	await screen.findByText("ads.example");

	fireEvent.click(screen.getByRole("button", { name: "Clear" }));
	await waitFor(() => {
		expect(history.location.search).not.toContain("domain");
	});
	expect(history.location.search).not.toContain("blocked");
	expect(screen.getByLabelText("Domain contains")).toHaveProperty("value", "");
});
