import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { DATABASE, renderPage, stubApi, type Call } from "./testFixtures";

/**
 * Protection in database mode: the group-centred master/detail, the rules
 * scoped to the selected group, and the shared source catalogue with its one
 * runtime action.
 */

let calls: Call[];

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

async function openProtection(group?: number) {
	calls = stubApi(DATABASE);
	const suffix = group === undefined ? "" : `?group=${group}`;
	return renderPage(`/configuration/protection${suffix}`, "Protection");
}

function writes(method: string): Call[] {
	return calls.filter((call) => call.method === method);
}

test("the group list is the master, and the selected group is the detail", async () => {
	await openProtection();

	const list = within(screen.getByRole("navigation", { name: "Groups" }));
	expect(list.getByRole("link", { name: "default" })).toBeTruthy();
	expect(list.getByRole("link", { name: "kids" })).toBeTruthy();
	await screen.findByRole("heading", { name: "default", level: 2 });
});

test("the default group cannot be renamed or deleted, and says why", async () => {
	await openProtection(1);
	await screen.findByRole("heading", { name: "default", level: 2 });

	expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(true);
	expect((screen.getByRole("button", { name: "Delete group" }) as HTMLButtonElement).disabled).toBe(true);
	expect(screen.getByText("The default group cannot be renamed or deleted.")).toBeTruthy();
});

test("another group can be renamed and deleted, and carries its safe-search state", async () => {
	await openProtection(2);
	await screen.findByRole("heading", { name: "kids", level: 2 });

	expect((screen.getByRole("button", { name: "Rename group" }) as HTMLButtonElement).disabled).toBe(false);
	expect((screen.getByRole("switch", { name: "Safe search" }) as HTMLInputElement).checked).toBe(true);

	fireEvent.click(screen.getByRole("button", { name: "Delete group" }));
	const dialog = await screen.findByRole("alertdialog");
	expect(dialog.textContent).toContain('Delete group "kids"?');
	fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
	await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
	expect(writes("DELETE")).toEqual([]);
});

test("toggling safe search resends the whole group row", async () => {
	await openProtection(2);
	await screen.findByRole("heading", { name: "kids", level: 2 });

	fireEvent.click(screen.getByRole("switch", { name: "Safe search" }));

	await waitFor(() => expect(writes("PUT")).toHaveLength(1));
	expect(writes("PUT")[0]).toMatchObject({
		url: "/api/groups/2",
		body: { name: "kids", safe_search: false },
	});
});

test("the source assignment saves the full set via PUT", async () => {
	await openProtection(2);
	await screen.findByRole("heading", { name: "kids", level: 2 });

	const ads = (await screen.findByRole("checkbox", { name: "Ads" })) as HTMLInputElement;
	expect(ads.checked).toBe(false);
	const save = screen.getByRole("button", { name: "Save sources" }) as HTMLButtonElement;
	expect(save.disabled).toBe(true);

	fireEvent.click(ads);
	expect(save.disabled).toBe(false);
	fireEvent.click(save);

	await waitFor(() => expect(writes("PUT")).toHaveLength(1));
	expect(writes("PUT")[0]).toMatchObject({
		url: "/api/groups/2/sources",
		body: { source_ids: [1] },
	});
});

test("the source checkboxes form one named group, and each carries its own state", async () => {
	await openProtection(2);
	await screen.findByRole("heading", { name: "kids", level: 2 });

	// The section heading names the group; the boxes belong to it rather than
	// sitting loose beside the group's other checkboxes.
	const group = await screen.findByRole("group", { name: "Assigned sources" });
	const ads = within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement;
	expect(ads.checked).toBe(false);
	// Safe search is the group's own field, not one of its sources, and it is a
	// switch rather than a checkbox because it applies the moment it moves.
	expect(within(group).queryByRole("switch")).toBeNull();
	expect(screen.getByRole("switch", { name: "Safe search" })).toBeTruthy();

	fireEvent.click(ads);
	await waitFor(() => expect(ads.checked).toBe(true));

	// Discard returns the group to the server's set rather than clearing it.
	fireEvent.click(screen.getByRole("button", { name: "Discard" }));
	await waitFor(() =>
		expect((within(group).getByRole("checkbox", { name: "Ads" }) as HTMLInputElement).checked).toBe(false),
	);
	expect(writes("PUT")).toEqual([]);
});

test("only the selected group's rules are listed", async () => {
	await openProtection(2);
	await screen.findByRole("heading", { name: "kids", level: 2 });

	expect(await screen.findByText("*.social.example")).toBeTruthy();
	expect(screen.queryByText("ads.example.com")).toBeNull();
});

test("a new rule is created in the selected group, with the pattern posted verbatim for a regex", async () => {
	await openProtection(2);
	await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });

	// An exact pattern is trimmed; a regex is stored and matched byte for byte.
	fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "  ads.example.net  " } });
	fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
	await waitFor(() => expect(writes("POST")).toHaveLength(1));
	expect(writes("POST")[0]?.body).toEqual({
		group_id: 2,
		pattern: "ads.example.net",
		kind: "exact",
		action: "block",
	});
});

test("a rate-limited rule create shows the countdown from Retry-After", async () => {
	calls = stubApi(DATABASE, {
		onWrite: (call) =>
			call.url === "/api/rules"
				? new Response(JSON.stringify({ error: "rate limited" }), {
						status: 429,
						headers: { "content-type": "application/json", "Retry-After": "12" },
					})
				: null,
	});
	await renderPage("/configuration/protection?group=2", "Protection");
	await screen.findByRole("heading", { name: "Create rule in kids", level: 4 });

	fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: "ads.example.net" } });
	fireEvent.click(screen.getByRole("button", { name: "Create rule" }));

	expect((await screen.findByRole("alert")).textContent).toBe("Rate limited. Try again in 12s.");
});

test("cancelling the rule delete confirmation leaves the rule alone", async () => {
	await openProtection(2);
	await screen.findByText("*.social.example");

	fireEvent.click(screen.getByRole("button", { name: "Delete" }));
	const dialog = await screen.findByRole("alertdialog");
	fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));

	await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
	expect(writes("DELETE")).toEqual([]);
	expect(screen.getByText("*.social.example")).toBeTruthy();
});

test("the pattern field opts out of mobile autocapitalize and autocorrect", async () => {
	await openProtection(2);
	const pattern = await screen.findByLabelText("Pattern");

	expect(pattern.getAttribute("autocapitalize")).toBe("none");
	expect(pattern.getAttribute("autocorrect")).toBe("off");
	expect(pattern.getAttribute("spellcheck")).toBe("false");
});

test("the kind selector offers the three contract kinds and can pick regex", async () => {
	await openProtection(2);
	await screen.findByLabelText("Pattern");

	fireEvent.click(screen.getByRole("button", { name: /Kind$/ }));
	const options = await screen.findAllByRole("option");
	expect(options.map((option) => option.textContent)).toEqual(["exact", "wildcard", "regex"]);

	fireEvent.click(screen.getByRole("option", { name: "regex" }));
	await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());

	fireEvent.change(screen.getByLabelText("Pattern"), { target: { value: " ^ad[0-9]+- " } });
	fireEvent.click(screen.getByRole("button", { name: "Create rule" }));
	await waitFor(() => expect(writes("POST")).toHaveLength(1));
	expect(writes("POST")[0]?.body).toMatchObject({ pattern: " ^ad[0-9]+- ", kind: "regex" });
});

test("deleting a rule asks first, then issues the DELETE", async () => {
	await openProtection(2);
	await screen.findByText("*.social.example");

	fireEvent.click(screen.getByRole("button", { name: "Delete" }));
	const dialog = await screen.findByRole("alertdialog");
	expect(dialog.textContent).toContain('Delete the block rule for "*.social.example"?');
	fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));

	await waitFor(() => expect(writes("DELETE")).toHaveLength(1));
	expect(writes("DELETE")[0]?.url).toBe("/api/rules/2");
});

test("the client count links into Clients filtered by the group (D3)", async () => {
	await openProtection(2);

	const link = await screen.findByRole("link", { name: "1 client in this group" });
	expect(link.getAttribute("href")).toBe("/clients?group=2");
});

test("a group can be created from the master column", async () => {
	await openProtection();
	fireEvent.change(await screen.findByLabelText("New group"), { target: { value: " guests " } });
	fireEvent.click(screen.getByRole("button", { name: "Create" }));

	await waitFor(() => expect(writes("POST")).toHaveLength(1));
	expect(writes("POST")[0]).toMatchObject({ url: "/api/groups", body: { name: "guests" } });
});

test("the Sources tab lists the catalogue with both skipped columns and their note", async () => {
	calls = stubApi(DATABASE);
	await renderPage("/configuration/protection?tab=sources", "Protection");

	expect(await screen.findByText("Ads")).toBeTruthy();
	expect(screen.getByText("Trackers")).toBeTruthy();
	expect(screen.getByText("Suggested")).toBeTruthy();
	expect(screen.getByRole("columnheader", { name: "Skipped regex" })).toBeTruthy();
	expect(screen.getByRole("columnheader", { name: "Skipped unsupported" })).toBeTruthy();
	expect(
		screen.getByText(/Skipped unsupported lines are syntax nxdns cannot translate into a DNS decision/),
	).toBeTruthy();
	// Switches, not checkboxes: the row applies the moment it moves, and the role
	// is what tells a screen reader so.
	expect((screen.getByRole("switch", { name: "Ads enabled" }) as HTMLInputElement).checked).toBe(true);
	expect((screen.getByRole("switch", { name: "Trackers enabled" }) as HTMLInputElement).checked).toBe(false);
	expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});

test("a source switch resends the whole row immediately, with no Save step", async () => {
	calls = stubApi(DATABASE);
	await renderPage("/configuration/protection?tab=sources", "Protection");

	fireEvent.click(await screen.findByRole("switch", { name: "Trackers enabled" }));

	// No Save button stands between the switch and the write: one click, one PUT.
	await waitFor(() => expect(writes("PUT")).toHaveLength(1));
	expect(writes("PUT")[0]).toMatchObject({
		url: "/api/blocklists/2",
		// The whole row goes back, not a patch of the one field that moved.
		body: {
			url: "https://example.com/trackers.txt",
			name: "Trackers",
			enabled: true,
			is_suggested: true,
		},
	});
});

test("Update now says it started, and says nothing once it succeeds", async () => {
	let release: ((response: Response) => void) | null = null;
	calls = stubApi(DATABASE, {
		onWrite: (call) =>
			// Held open so the started state is observable, not a frame that resolves
			// before the assertion.
			call.url === "/api/blocklists/update"
				? new Promise<Response>((resolve) => {
						release = resolve;
					})
				: null,
	});
	await renderPage("/configuration/protection?tab=sources", "Protection");

	fireEvent.click(await screen.findByRole("button", { name: "Update now" }));
	const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
	expect(pending.disabled).toBe(true);
	expect(screen.getByRole("status").textContent).toBe("Update started…");

	release!(
		new Response(JSON.stringify({ sources: [] }), {
			status: 202,
			headers: { "content-type": "application/json" },
		}),
	);
	await waitFor(() => expect(screen.getByRole("button", { name: "Update now" })).toBeTruthy());
	// Success leaves no standing claim behind: the refreshed counters are the
	// signal, and a "counters refreshed" line would outlive a failed refetch.
	expect(screen.queryByRole("status")).toBeNull();
});

test("a rate-limited Update now shows the countdown from Retry-After", async () => {
	calls = stubApi(DATABASE, {
		onWrite: (call) =>
			call.url === "/api/blocklists/update"
				? new Response(JSON.stringify({ error: "rate limited" }), {
						status: 429,
						headers: { "content-type": "application/json", "Retry-After": "7" },
					})
				: null,
	});
	await renderPage("/configuration/protection?tab=sources", "Protection");

	fireEvent.click(await screen.findByRole("button", { name: "Update now" }));

	const alert = await screen.findByRole("alert");
	expect(alert.textContent).toBe("Rate limited. Try again in 7s.");
});

test("deleting a source asks first, then issues the DELETE for that source", async () => {
	calls = stubApi(DATABASE);
	await renderPage("/configuration/protection?tab=sources", "Protection");
	await screen.findByText("Ads");

	fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
	const dialog = await screen.findByRole("alertdialog");
	expect(dialog.textContent).toContain('Delete blocklist "Trackers"? Its domains stop being blocked.');
	fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));

	await waitFor(() => expect(writes("DELETE")).toHaveLength(1));
	expect(writes("DELETE")[0]?.url).toBe("/api/blocklists/2");
});

test("cancelling the source delete confirmation leaves the source alone", async () => {
	calls = stubApi(DATABASE);
	await renderPage("/configuration/protection?tab=sources", "Protection");
	await screen.findByText("Ads");

	fireEvent.click(screen.getAllByRole("button", { name: "Delete" })[1]!);
	const dialog = await screen.findByRole("alertdialog");
	fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));

	await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
	expect(writes("DELETE")).toEqual([]);
	expect(screen.getByText("Trackers")).toBeTruthy();
});
