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

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

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 });
	await screen.findAllByLabelText(/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);
});

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.findAllByLabelText(/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.
//
// 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/;
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).getByLabelText(/^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 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" }));
		expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Confirm delete" })).toBeTruthy();

		await setConfigStatus(map, queryClient, status);

		const confirming = clientRow("192.168.1.10");
		expect(within(confirming).queryByRole("button", { name: "Confirm delete" })).toBeNull();
		expect(within(confirming).getByText(lockNote)).toBeTruthy();
		expect(within(confirming).queryByText(otherNote)).toBeNull();
		expect(within(confirming).getByLabelText(/^Locked\./)).toBeTruthy();

		fireEvent.click(within(confirming).getByRole("button", { name: "Cancel" }));
		await waitFor(() =>
			expect(within(clientRow("192.168.1.10")).getByRole("button", { name: "Delete" })).toBeTruthy(),
		);
		expect(fetchMock.mock.calls.filter(([, init]) => init?.method === "DELETE")).toEqual([]);
	});

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

		await setConfigStatus(map, queryClient, status);

		fireEvent.click(within(clientRow("192.168.1.11")).getByRole("button", { name: "Confirm 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.findAllByLabelText(/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);
});
