import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { ClientName, type ClientNames } from "./clientNames";
import { BASE, CLIENTS, MANAGED_FILE, NEVER, renderClientsPage, setConfigStatus } from "./testFixtures";

/** 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(" ");
}

function clientRow(ip: string): HTMLElement {
	const row = screen.getByText(ip).closest("tr");
	if (row === null) throw new Error(`no client row for ${ip}`);
	return row;
}

function assignmentsSection(): HTMLElement {
	const heading = screen.getByRole("heading", { name: /Network assignments/ });
	const section = heading.closest("section");
	if (section === null) throw new Error("no network assignments section");
	return section;
}

/**
 * Editing a client writes configuration, so the affordance is absent until the
 * status query says the database owns it — never disabled, never assumed.
 */
async function unlockedEdit(index: number): Promise<HTMLButtonElement> {
	const buttons = await screen.findAllByRole("button", { name: "Edit" });
	return buttons[index] as HTMLButtonElement;
}

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

test("renders the identity-first table: address, name, group, first and last seen", async () => {
	await renderClientsPage();

	const headers = screen.getAllByRole("columnheader").map((cell) => cell.textContent);
	expect(headers).toEqual(["Address", "Name", "Group", "First seen", "Last seen", "Actions"]);
	expect(screen.getByText("192.168.1.10")).toBeTruthy();
	expect(screen.getByText("192.168.1.11")).toBeTruthy();
	expect(screen.getByText("laptop")).toBeTruthy();
	expect(screen.getAllByRole("cell", { name: "kids" })).toHaveLength(1);
});

test("a named row shows the typed name and hides the learned one", async () => {
	await renderClientsPage();

	expect(screen.getByText("laptop")).toBeTruthy();
	expect(screen.queryByText("laptop-1.lan")).toBeNull();
});

test("an unnamed row shows the learned name with the learned marker", async () => {
	await renderClientsPage();

	// The cell holds the learned name followed by the marker, so the match is on
	// the containing span rather than on a bare text node.
	const learned = screen.getByText(
		(content, element) => element?.tagName === "SPAN" && content.startsWith("kids-tablet.lan"),
	);
	// The marker is text, not colour, so a screen reader announces it too.
	expect(within(learned).getByText("learned")).toBeTruthy();
});

test("the learned marker is a Clients-page affordance: query tables render the same name without it", () => {
	const names: ClientNames = new Map([["192.168.1.11", { name: "", learned_name: "kids-tablet.lan" }]]);
	render(<ClientName ip="192.168.1.11" names={names} />);

	expect(screen.getByText("kids-tablet.lan")).toBeTruthy();
	expect(screen.queryByText("learned")).toBeNull();
});

test("the address links to the client's detail page", async () => {
	await renderClientsPage();

	const link = within(clientRow("192.168.1.10")).getByRole("link", { name: "192.168.1.10" });
	expect(link.getAttribute("href")).toBe("/clients/1");
});

test("delete asks first, naming the row, and the confirmation carries out the delete", async () => {
	const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });

	fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Delete" }));

	const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
	// The operator has to be able to tell from the dialog alone which row this is.
	expect(within(dialog).getByText(/kids-tablet\.lan \(192\.168\.1\.11\)/)).toBeTruthy();
	expect(within(dialog).getByText(/re-materialize on their next DNS query/)).toBeTruthy();
	// Asking is not deleting.
	expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);

	fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
	await waitFor(() =>
		expect(
			fetchMock.mock.calls.filter(([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2")),
		).toHaveLength(1),
	);
	await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
});

test("cancelling the confirmation keeps the client", async () => {
	const { fetchMock } = await renderClientsPage({ ...BASE, "DELETE /api/clients/2": {} });

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

	await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
	expect(screen.getByText("192.168.1.11")).toBeTruthy();
	expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
});

test("shows the DNS-activity empty state when there are no clients", async () => {
	await renderClientsPage({ ...BASE, "GET /api/clients": { clients: [] } });

	expect(screen.getByText(/rows appear automatically as devices on the network make dns queries/i)).toBeTruthy();
	expect(screen.queryByRole("table")).toBeNull();
});

test("edit opens a dialog seeded with the client's name and group", async () => {
	await renderClientsPage();

	fireEvent.click(await unlockedEdit(0));
	// The dialog portals out of the table, so every field query is scoped to it.
	const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
	expect((dialog.getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
	// The RAC Select names its trigger with the value and then the label.
	expect(dialog.getByRole("button", { name: /Group$/ }).textContent).toContain("default");
});

test("the group picker offers every group and reports the choice", async () => {
	await renderClientsPage();

	fireEvent.click(await unlockedEdit(0));
	const dialog = within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" }));
	fireEvent.click(dialog.getByRole("button", { name: /Group$/ }));

	const options = await screen.findAllByRole("option");
	expect(options.map((option) => option.textContent)).toEqual(["default", "kids"]);

	fireEvent.click(screen.getByRole("option", { name: "kids" }));
	expect(screen.getByRole("button", { name: /Group$/ }).textContent).toContain("kids");
});

test("network assignments start clean and dirty on add", async () => {
	await renderClientsPage();

	const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
	expect(range.value).toBe("192.168.1.0/24");
	const save = screen.getByRole("button", { name: "Save assignments" }) as HTMLButtonElement;
	expect(save.disabled).toBe(true);

	fireEvent.click(screen.getByRole("button", { name: "Add range" }));
	expect(save.disabled).toBe(false);
	expect((screen.getByLabelText("Range 2") as HTMLInputElement).value).toBe("");
});

test("?group= filters the list to that group", async () => {
	const { router } = await renderClientsPage();
	await router.navigate({ to: "/clients", search: { group: 2 } });

	await waitFor(() => expect(screen.queryByText("192.168.1.10")).toBeNull());
	expect(screen.getByText("192.168.1.11")).toBeTruthy();
	expect(screen.getByText("Showing clients in kids.")).toBeTruthy();
});

test("an unknown group id filters to nothing and offers a way out", async () => {
	const { router } = await renderClientsPage();
	await router.navigate({ to: "/clients", search: { group: 999 } });

	await screen.findByText("No group with id 999 exists.");
	expect(screen.getByText("No clients match this filter.")).toBeTruthy();
	expect(screen.queryByRole("table")).toBeNull();

	fireEvent.click(screen.getByRole("link", { name: "Clear filter" }));
	await waitFor(() => expect(screen.getByText("192.168.1.10")).toBeTruthy());
	expect(router.state.location.search).toEqual({});
});

// Both statuses lock the declared delete, but only file authority proves the
// file declares the row; the anchors keep the two sentences apart.
const DECLARED_NOTE = /^This client is declared in the configuration file/;
const UNKNOWN_NOTE = /^nxdns cannot say whether this client is declared/;
test("file mode drops every edit affordance and keeps the observed delete live (R2-4)", async () => {
	await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
	// The settled sentence, not the tag: "Locked" is already on screen while
	// authority is pending, so waiting on it would not wait for this status.
	await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);

	expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);

	const declared = clientRow("192.168.1.10");
	const observed = clientRow("192.168.1.11");
	expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
	expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);

	// Why the locked Delete will not answer, in visible text and exactly once:
	// per row it would repeat down the whole page, and on the button it was a
	// title that a keyboard and a touch screen never reached.
	expect(screen.getAllByText(DECLARED_NOTE, { selector: "p" })).toHaveLength(1);
	expect(within(declared).queryByText(DECLARED_NOTE, { selector: "p" })).toBeNull();
	// The description stays on the button itself too, for a reader on that control.
	const locked = within(declared).getByRole("button", { name: "Delete" });
	expect(document.getElementById(locked.getAttribute("aria-describedby") ?? "")?.textContent).toMatch(DECLARED_NOTE);
});

test("an all-observed page still says why Edit is gone, with no delete note to carry it", async () => {
	// Every row observed, so no Delete is locked. The edit lock is still real, and
	// "Locked" appearing with nothing to explain it is the failure this guards.
	const observedOnly = { clients: [CLIENTS.clients[1]] };
	await renderClientsPage({
		...BASE,
		"GET /api/clients": observedOnly,
		"GET /api/config/status": MANAGED_FILE,
	});
	// The settled sentence is both the anchor and the assertion: it is the whole
	// explanation for the missing Edit action.
	await screen.findAllByText(/^Managed by \/etc\/nxdns\/config\.zon/);
	expect(await screen.findAllByText("Locked")).not.toHaveLength(0);

	// The delete sentence belongs only to a row that has one.
	expect(screen.queryByText(DECLARED_NOTE)).toBeNull();
	expect((screen.getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});

test("file mode renders network assignments with no mutation control at all (R2-4)", async () => {
	await renderClientsPage({ ...BASE, "GET /api/config/status": MANAGED_FILE });
	const section = await waitFor(() => {
		const found = assignmentsSection();
		if (found.querySelector("table") === null) throw new Error("still editing");
		return found;
	});

	expect(within(section).getByText("192.168.1.0/24")).toBeTruthy();
	expect(
		section.querySelectorAll(
			"input, textarea, select, [role='combobox'], [role='checkbox'], [contenteditable], button",
		),
	).toHaveLength(0);
});

test("a failed config status exposes no configuration mutation, and still deletes an observed client (R3-4)", async () => {
	await renderClientsPage({ ...BASE, "GET /api/config/status": undefined });
	await screen.findAllByText(/^Configuration status unavailable/);

	expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
	expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
	expect(within(assignmentsSection()).getByText(/Configuration status unavailable/)).toBeTruthy();

	const observed = clientRow("192.168.1.11");
	expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
	const declared = clientRow("192.168.1.10");
	expect((within(declared).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(true);
});

// The status query polls, so authority can turn while a dialog or a delete
// confirmation is already open. `undefined` is the failed status: the fetch stub
// answers 404 for a key it does not hold.
//
describe.each([
	["file authority", MANAGED_FILE, DECLARED_NOTE, UNKNOWN_NOTE],
	["a failed status", undefined, UNKNOWN_NOTE, DECLARED_NOTE],
])("authority turning to %s under an open affordance", (_label, status, lockNote, otherNote) => {
	test("locks the open edit dialog's save path and leaves cancel working", async () => {
		const map = { ...BASE };
		const { queryClient } = await renderClientsPage(map);

		fireEvent.click(await unlockedEdit(0));
		expect(
			within(await screen.findByRole("dialog", { name: "Edit client 192.168.1.10" })).getByRole("button", {
				name: "Save",
			}),
		).toBeTruthy();

		await setConfigStatus(map, queryClient, status);

		// The draft survives; only the save path goes, and it says why.
		const dialog = screen.getByRole("dialog", { name: "Edit client 192.168.1.10" });
		expect(within(dialog).queryByRole("button", { name: "Save" })).toBeNull();
		expect((within(dialog).getByLabelText("Name") as HTMLInputElement).value).toBe("laptop");
		expect(within(dialog).getByText(/can no longer be saved/)).toBeTruthy();
		expect(within(dialog).getByText("Locked")).toBeTruthy();

		fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
		await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
	});

	test("locks an open declared-client delete confirmation in place and leaves cancel working", async () => {
		const map = { ...BASE };
		const { queryClient, fetchMock } = await renderClientsPage(map);
		// The Edit affordance appearing is the proof authority resolved to
		// database; the transition under test starts from there.
		await unlockedEdit(0);

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

		await setConfigStatus(map, queryClient, status);

		// The dialog stays put. Closing it would throw focus at the Delete button
		// the same turn disabled, and the reason would survive only as a title
		// attribute; here the reason is the dialog's own message.
		await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());
		expect(within(dialog).getByText(lockNote)).toBeTruthy();
		expect(within(dialog).queryByText(otherNote)).toBeNull();
		expect(within(dialog).getByText("Locked")).toBeTruthy();

		fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
		await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
		expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
	});

	test("a cancelled confirmation stays closed when authority comes back", async () => {
		const map = { ...BASE };
		const { queryClient } = await renderClientsPage(map);
		await unlockedEdit(0);

		fireEvent.click(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" }));
		const dialog = await screen.findByRole("alertdialog", { name: "Delete client" });
		fireEvent.click(within(dialog).getByRole("button", { name: "Cancel" }));
		await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());

		// A poll that fails and then recovers must not raise a destructive
		// question the operator already answered.
		await setConfigStatus(map, queryClient, status);
		await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);

		await waitFor(() => expect(screen.getAllByRole("button", { name: "Edit" }).length).toBeGreaterThan(0));
		expect(screen.queryByRole("alertdialog")).toBeNull();
	});

	test("the confirm action comes back when authority does, without a second prompt", async () => {
		const map = { ...BASE, "DELETE /api/clients/1": {} };
		const { queryClient, fetchMock } = await renderClientsPage(map);
		await unlockedEdit(0);

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

		await setConfigStatus(map, queryClient, status);
		await waitFor(() => expect(within(dialog).queryByRole("button", { name: "Delete" })).toBeNull());

		await setConfigStatus(map, queryClient, BASE["GET /api/config/status"]);

		// The question was never withdrawn, so the answer returns to the same
		// dialog rather than asking the operator to start again.
		const confirm = await within(dialog).findByRole("button", { name: "Delete" });
		fireEvent.click(confirm);
		await waitFor(() =>
			expect(
				fetchMock.mock.calls.filter(
					([input, init]) => init?.method === "DELETE" && String(input).endsWith("/1"),
				),
			).toHaveLength(1),
		);
	});

	test("keeps an open observed-client delete confirmation live (R3-4)", async () => {
		const map = { ...BASE, "DELETE /api/clients/2": {} };
		const { queryClient, fetchMock } = await renderClientsPage(map);
		// The Edit affordance appearing is the proof authority resolved to
		// database; the transition under test starts from there.
		await unlockedEdit(0);

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

		await setConfigStatus(map, queryClient, status);

		fireEvent.click(within(dialog).getByRole("button", { name: "Delete" }));
		await waitFor(() =>
			expect(
				fetchMock.mock.calls.filter(
					([input, init]) => init?.method === "DELETE" && String(input).endsWith("/2"),
				),
			).toHaveLength(1),
		);
	});
});

test("a pending config status holds the same line as a failed one (R3-4)", async () => {
	// The status request never settles, so authority stays pending for the whole
	// test: nothing configuration owns may be offered on that guess.
	await renderClientsPage({ ...BASE, "GET /api/config/status": NEVER });
	await screen.findAllByText(/^Checking which configuration source/);

	expect(screen.queryAllByRole("button", { name: "Edit" })).toEqual([]);
	expect(screen.queryByRole("button", { name: "Save assignments" })).toBeNull();
	expect(within(assignmentsSection()).getByText(/Checking which configuration source/)).toBeTruthy();

	const observed = clientRow("192.168.1.11");
	expect((within(observed).getByRole("button", { name: "Delete" }) as HTMLButtonElement).disabled).toBe(false);
});

test("a save-time problem marks the input it is about and describes it", async () => {
	await renderClientsPage();

	const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
	fireEvent.change(range, { target: { value: "" } });
	fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));

	expect(range.getAttribute("aria-invalid")).toBe("true");
	expect(describedText(range)).toBe("Row 1: prefix is required.");

	// Only the offending input is marked; the priority beside it is untouched.
	const priority = screen.getByLabelText("Priority for range 1");
	expect(priority.getAttribute("aria-invalid")).toBeNull();
	expect(priority.getAttribute("aria-describedby")).toBeNull();

	fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
	fireEvent.change(screen.getByLabelText("Priority for range 1"), { target: { value: "abc" } });
	fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));

	expect(range.getAttribute("aria-invalid")).toBeNull();
	expect(describedText(screen.getByLabelText("Priority for range 1"))).toBe(
		"Row 1: priority must be a whole number.",
	);
});

test("removing a row drops the message rather than moving it to another input", async () => {
	await renderClientsPage();

	// Row 1 is the offending one, so removing it is what would slide the stale
	// index onto row 2 — an input that validated cleanly.
	const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
	fireEvent.change(range, { target: { value: "" } });
	fireEvent.click(screen.getByRole("button", { name: "Add range" }));
	fireEvent.change(screen.getByLabelText("Range 2"), { target: { value: "10.0.0.0/8" } });
	fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));

	expect(range.getAttribute("aria-invalid")).toBe("true");
	expect(describedText(range)).toBe("Row 1: prefix is required.");

	const section = assignmentsSection();
	fireEvent.click(within(section).getAllByRole("button", { name: "Remove" })[0] as HTMLButtonElement);

	const survivor = screen.getByLabelText("Range 1") as HTMLInputElement;
	expect(survivor.value).toBe("10.0.0.0/8");
	expect(survivor.getAttribute("aria-invalid")).toBeNull();
	expect(survivor.getAttribute("aria-describedby")).toBeNull();
	expect(within(section).queryByRole("alert")).toBeNull();
	expect(screen.queryByLabelText("Range 2")).toBeNull();
});

test("editing a row clears the message it was about", async () => {
	await renderClientsPage();

	const range = (await screen.findByLabelText("Range 1")) as HTMLInputElement;
	fireEvent.change(range, { target: { value: "" } });
	fireEvent.click(screen.getByRole("button", { name: "Save assignments" }));
	expect(range.getAttribute("aria-invalid")).toBe("true");

	fireEvent.change(range, { target: { value: "10.0.0.0/8" } });
	expect(range.getAttribute("aria-invalid")).toBeNull();
	expect(within(assignmentsSection()).queryByRole("alert")).toBeNull();
});
