import { act, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { queryKeys } from "@/lib/queries";
import type { Settings, SettingsPatch } from "@/lib/types";
import { DATABASE, MANAGED_FILE, RESTART_REQUIRED_KEYS, baseSettings, renderPage, stubApi } from "./testFixtures";

/**
 * System in database mode: the settings form, its diff contract, and the
 * certificate reload that is a runtime action under both authorities.
 */

// What the server actually reports: the listener binds and `web.enabled`.
// Every other key applies live, so it carries no mark at all.
const RESTART_KEYS = RESTART_REQUIRED_KEYS;

let stored: Settings;
let putBodies: SettingsPatch[];
let putResponse: (() => Response | Promise<Response>) | null;
let restartPending: boolean;

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

/** The server's echo: the patch merged into the stored settings, password excepted. */
function applyPatch(patch: SettingsPatch): void {
	const target = stored as unknown as Record<string, Record<string, unknown>>;
	for (const [section, fields] of Object.entries(patch)) {
		for (const [key, value] of Object.entries(fields as Record<string, unknown>)) {
			if (section === "web" && key === "password") continue;
			target[section]![key] = value;
		}
	}
}

/** Mirrors apply.zig's table: only a listed key leaves the server owing a restart. */
function needsRestart(patch: SettingsPatch, keys: readonly string[]): boolean {
	return Object.entries(patch).some(([section, fields]) =>
		Object.keys(fields as Record<string, unknown>).some((key) => keys.includes(`${section}.${key}`)),
	);
}

beforeEach(() => {
	stored = baseSettings();
	putBodies = [];
	putResponse = null;
	restartPending = false;
});

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

async function openSystem(restartKeys: readonly string[] = RESTART_KEYS) {
	stubApi(DATABASE, {
		responses: {
			"GET /api/config/status": () => ({ ...DATABASE, restart_pending: restartPending }),
			"GET /api/settings": () => ({ settings: stored, restart_required: restartKeys }),
		},
		onWrite: (call) => {
			if (call.url !== "/api/settings") return null;
			const patch = call.body as SettingsPatch;
			putBodies.push(patch);
			if (putResponse !== null) return putResponse();
			applyPatch(patch);
			if (needsRestart(patch, restartKeys)) restartPending = true;
			return json({ settings: stored, restart_required: restartKeys });
		},
	});
	const router = await renderPage("/configuration/system", "System");
	await screen.findByRole("button", { name: "Save" });
	return router;
}

function saveButton(): HTMLButtonElement {
	return screen.getByRole("button", { name: "Save" }) as HTMLButtonElement;
}

function restartNotice(): HTMLElement | null {
	return screen.queryByText(/Saved changes are not running yet/);
}

test("no changes means Save is disabled, and authentication reads as derived", async () => {
	await openSystem();

	expect(saveButton().disabled).toBe(true);
	const auth = screen.getByText(/^Authentication: required/);
	expect(auth.textContent).toContain("derived from whether a password is stored");
});

test("a changed field enables Save and the PUT body is exactly the diff", async () => {
	await openSystem();

	const dns = screen.getByRole("group", { name: "DNS" });
	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
	expect(saveButton().disabled).toBe(false);

	fireEvent.click(saveButton());
	await waitFor(() => expect(putBodies).toHaveLength(1));
	expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
	await waitFor(() => expect(saveButton().disabled).toBe(true));
});

test("a restart-required key is marked as one, from the envelope's list", async () => {
	await openSystem();

	// The DNS binds and the port are the section's whole share of the list;
	// `rate_limit` and `rate_window_seconds` apply live and carry no mark.
	const dns = screen.getByRole("group", { name: "DNS" });
	expect(within(dns).getAllByText("needs restart")).toHaveLength(3);
	const cache = screen.getByRole("group", { name: "Cache" });
	expect(within(cache).queryByText("needs restart")).toBeNull();
});

test("every key the server applies live is drawn without restart messaging", async () => {
	await openSystem();

	// Silence is the report for a live key: no mark on the field, and editing
	// one owes nothing afterwards either.
	for (const title of ["Upstream", "Blocking", "Cache", "EDNS", "Logging", "Disk", "Blocklist Update"]) {
		const section = screen.getByRole("group", { name: title });
		expect(within(section).queryByText("needs restart")).toBeNull();
	}

	const logging = screen.getByRole("group", { name: "Logging" });
	fireEvent.change(within(logging).getByLabelText("retention_days"), { target: { value: "14" } });
	fireEvent.click(saveButton());

	await waitFor(() => expect(putBodies).toHaveLength(1));
	expect(putBodies[0]).toEqual({ logging: { retention_days: 14 } });
	await waitFor(() => expect(saveButton().disabled).toBe(true));
	expect(restartNotice()).toBeNull();
});

test("a port edit still owes a restart, and the shell says so", async () => {
	await openSystem();

	const dns = screen.getByRole("group", { name: "DNS" });
	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
	fireEvent.click(saveButton());

	await waitFor(() => expect(putBodies).toHaveLength(1));
	await screen.findByText(/Saved changes are not running yet/);
});

test("an enum-backed key on the list is marked too, not only text and number fields", async () => {
	// No shipped restart-required key is enum-backed, but the list is the
	// server's to change, so the `Select` rendering is pinned against one.
	await openSystem(["logging.level"]);

	const logging = screen.getByRole("group", { name: "Logging" });
	// `logging.level` is a Select and `logging.output` is not on the list, so
	// exactly one mark belongs to this section.
	expect(within(logging).getAllByText("needs restart")).toHaveLength(1);
	const marker = within(logging).getByText("needs restart");
	const marked = marker.parentElement!;
	const trigger = within(marked).getByRole("button", { name: /level$/ });
	// Visible next to the control is not enough: the marker sits outside the
	// label, so only `aria-describedby` carries it to a screen reader.
	expect(marker.id).not.toBe("");
	expect(trigger.getAttribute("aria-describedby")?.split(" ")).toContain(marker.id);
});

test("enum and boolean fields diff as their own types", async () => {
	await openSystem();

	const logging = screen.getByRole("group", { name: "Logging" });
	// A RAC Select names its trigger with the current value and then the label,
	// and carries the options only while the listbox is open.
	fireEvent.click(within(logging).getByRole("button", { name: /level$/ }));
	fireEvent.click(await screen.findByRole("option", { name: "debug" }));
	await waitFor(() => expect(screen.queryByRole("listbox")).toBeNull());
	fireEvent.click(within(logging).getByLabelText("hide_domains"));
	fireEvent.click(saveButton());

	await waitFor(() => expect(putBodies).toHaveLength(1));
	expect(putBodies[0]).toEqual({ logging: { level: "debug", hide_domains: true } });
});

/** The text of the elements an input points at with `aria-describedby`. */
function describedText(input: HTMLElement): string {
	const ids = input.getAttribute("aria-describedby");
	if (ids === null) throw new Error("input has no aria-describedby");
	return ids
		.split(/\s+/)
		.map((id) => {
			const node = document.getElementById(id);
			if (node === null) throw new Error(`aria-describedby names missing element ${id}`);
			return node.textContent ?? "";
		})
		.join(" ");
}

test("clearing a number field disables Save instead of sending NaN", async () => {
	await openSystem();

	const cache = screen.getByRole("group", { name: "Cache" });
	const size = within(cache).getByLabelText("size");
	fireEvent.change(size, { target: { value: "" } });
	expect(saveButton().disabled).toBe(true);
	// The refusal is on the field itself, not only on the Save button.
	expect(size.getAttribute("aria-invalid")).toBe("true");

	fireEvent.change(size, { target: { value: "512" } });
	expect(size.getAttribute("aria-invalid")).toBeNull();
});

test("the mismatch message is attached to both password inputs", async () => {
	await openSystem();

	const web = screen.getByRole("group", { name: "Web" });
	const passwordInput = within(web).getByLabelText("password");
	const confirmInput = within(web).getByLabelText("confirm password");

	fireEvent.change(passwordInput, { target: { value: "hunter2" } });
	for (const input of [passwordInput, confirmInput]) {
		expect(input.getAttribute("aria-invalid")).toBe("true");
		expect(describedText(input)).toBe("Passwords do not match.");
	}

	fireEvent.change(confirmInput, { target: { value: "hunter2" } });
	for (const input of [passwordInput, confirmInput]) {
		expect(input.getAttribute("aria-invalid")).toBeNull();
		expect(input.getAttribute("aria-describedby")).toBeNull();
	}
});

test("password flow: note shown, confirm required, PUT sends web.password, no restart notice", async () => {
	await openSystem();

	const web = screen.getByRole("group", { name: "Web" });
	const passwordInput = within(web).getByLabelText("password") as HTMLInputElement;
	const confirmInput = within(web).getByLabelText("confirm password") as HTMLInputElement;
	expect(passwordInput.value).toBe("");

	fireEvent.change(passwordInput, { target: { value: "hunter2" } });
	expect(screen.getByText(/signs out every session/)).toBeTruthy();
	expect(screen.getByText("Passwords do not match.")).toBeTruthy();
	expect(saveButton().disabled).toBe(true);

	fireEvent.change(confirmInput, { target: { value: "hunter2" } });
	expect(screen.queryByText("Passwords do not match.")).toBeNull();
	expect(saveButton().disabled).toBe(false);

	fireEvent.click(saveButton());
	await waitFor(() => expect(putBodies).toHaveLength(1));
	expect(putBodies[0]).toEqual({ web: { password: "hunter2" } });

	await waitFor(() => expect(passwordInput.value).toBe(""));
	expect(confirmInput.value).toBe("");
	// The password applies live, so the server never raises the flag.
	expect(restartNotice()).toBeNull();
});

test("a patch of live keys alone leaves the server owing nothing", async () => {
	await openSystem();

	const web = screen.getByRole("group", { name: "Web" });
	fireEvent.change(within(web).getByLabelText("session_ttl_hours"), { target: { value: "48" } });
	fireEvent.change(within(web).getByLabelText("password"), { target: { value: "hunter2" } });
	fireEvent.change(within(web).getByLabelText("confirm password"), { target: { value: "hunter2" } });
	fireEvent.click(saveButton());

	await waitFor(() => expect(putBodies).toHaveLength(1));
	expect(putBodies[0]).toEqual({ web: { session_ttl_hours: 48, password: "hunter2" } });
	await waitFor(() => expect(saveButton().disabled).toBe(true));
	expect(restartNotice()).toBeNull();
});

test("the form is disabled while the PUT is pending and re-enabled after success", async () => {
	await openSystem();
	let resolvePut!: (response: Response) => void;
	putResponse = () => new Promise<Response>((resolve) => (resolvePut = resolve));

	const dns = screen.getByRole("group", { name: "DNS" });
	const port = within(dns).getByLabelText(/^port/) as HTMLInputElement;
	fireEvent.change(port, { target: { value: "5353" } });
	fireEvent.click(saveButton());

	await screen.findByRole("button", { name: "Saving…" });
	expect(port.matches(":disabled")).toBe(true);
	expect(screen.getByRole("group", { name: "Web" }).querySelector("#web\\.password")?.matches(":disabled")).toBe(
		true,
	);

	applyPatch(putBodies[putBodies.length - 1]!);
	resolvePut(json({ settings: stored, restart_required: RESTART_KEYS }));
	await waitFor(() => expect(port.matches(":disabled")).toBe(false));
	expect(saveButton().textContent).toBe("Save");
});

test("a 429 shows the rate-limit countdown from Retry-After", async () => {
	await openSystem();
	putResponse = () =>
		new Response(JSON.stringify({ error: "too many requests" }), {
			status: 429,
			headers: { "content-type": "application/json", "Retry-After": "30" },
		});

	const dns = screen.getByRole("group", { name: "DNS" });
	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
	fireEvent.click(saveButton());

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

test("a 400 validation error surfaces inline and owes no restart", async () => {
	await openSystem();
	putResponse = () => json({ error: "dns.port out of range" }, 400);

	const dns = screen.getByRole("group", { name: "DNS" });
	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "70000" } });
	fireEvent.click(saveButton());

	expect((await screen.findByRole("alert")).textContent).toBe("dns.port out of range");
	expect(restartNotice()).toBeNull();
	expect(saveButton().disabled).toBe(false);
});

test("a background refetch does not turn out-of-band changes into phantom patch entries", async () => {
	const router = await openSystem();
	const queryClient = router.options.context.queryClient;

	const dns = screen.getByRole("group", { name: "DNS" });
	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });

	// Someone else changes cache.size; a background refetch brings it in. The
	// derived authentication line is read straight from the query data, so it
	// witnesses that the refetch reached the component.
	stored.cache.size = 99999;
	stored.web.auth_enabled = false;
	await act(async () => {
		await queryClient.invalidateQueries({ queryKey: queryKeys.settings });
	});
	await waitFor(() => expect(screen.getByText(/^Authentication: not configured/)).toBeTruthy());
	const cache = screen.getByRole("group", { name: "Cache" });
	expect((within(cache).getByLabelText("size") as HTMLInputElement).value).toBe("10000");

	fireEvent.click(saveButton());
	await waitFor(() => expect(putBodies).toHaveLength(1));
	expect(putBodies[0]).toEqual({ dns: { port: 5353 } });
});

test("saving re-freezes the baseline, so the next diff starts from the server echo", async () => {
	await openSystem();
	const dns = screen.getByRole("group", { name: "DNS" });

	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5353" } });
	fireEvent.click(saveButton());
	await waitFor(() => expect(putBodies).toHaveLength(1));
	await waitFor(() => expect(saveButton().disabled).toBe(true));

	fireEvent.change(within(dns).getByLabelText(/^port/), { target: { value: "5454" } });
	fireEvent.click(saveButton());
	await waitFor(() => expect(putBodies).toHaveLength(2));
	expect(putBodies[1]).toEqual({ dns: { port: 5454 } });
});

test("Reload certificates reports each endpoint's outcome in database mode (D8)", async () => {
	stubApi(DATABASE, {
		responses: {
			"POST /api/certs/reload": {
				doh: { enabled: true, reloaded: true, error: null },
				dot: { enabled: false, reloaded: false, error: null },
			},
		},
	});
	await renderPage("/configuration/system", "System");

	fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
	const result = await screen.findByText(/DoH: reloaded/);
	expect(result.textContent).toContain("DoT: not enabled");
});

test("Reload certificates works under file authority too, and states a failure (D8)", async () => {
	stubApi(MANAGED_FILE, {
		responses: {
			"POST /api/certs/reload": {
				doh: { enabled: true, reloaded: false, error: "cert.pem: no such file" },
				dot: { enabled: true, reloaded: true, error: null },
			},
		},
	});
	await renderPage("/configuration/system", "System");

	const button = screen.getByRole("button", { name: "Reload certificates" }) as HTMLButtonElement;
	expect(button.disabled).toBe(false);
	fireEvent.click(button);

	const result = await screen.findByText(/DoH: failed/);
	expect(result.textContent).toContain("cert.pem: no such file");
	expect(result.textContent).toContain("DoT: reloaded");
});

test("a failed certificate reload request is an error, not an outcome", async () => {
	stubApi(DATABASE, {
		onWrite: (call) => (call.url === "/api/certs/reload" ? json({ error: "reload is busy" }, 409) : null),
	});
	await renderPage("/configuration/system", "System");

	fireEvent.click(screen.getByRole("button", { name: "Reload certificates" }));
	expect((await screen.findByRole("alert")).textContent).toBe("reload is busy");
});
