import { act, fireEvent, render, screen, waitFor } 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 { clearRefreshStatus } from "@/features/blocklists/refreshStore";

const BLOCKLISTS = {
	blocklists: [
		{
			id: 1,
			url: "https://example.com/hosts.txt",
			name: "StevenBlack",
			enabled: true,
			is_suggested: true,
			last_updated: 1700000000,
			domain_count: 1000,
			wildcard_count: 10,
			exception_count: 7,
			skipped_regex_count: 3,
			skipped_unsupported_count: 21,
			checksum: "abc",
		},
		{
			id: 2,
			url: "https://example.org/list.txt",
			name: "Custom",
			enabled: false,
			is_suggested: false,
			last_updated: null,
			domain_count: 0,
			wildcard_count: 0,
			exception_count: 0,
			skipped_regex_count: 0,
			skipped_unsupported_count: 0,
			checksum: null,
		},
	],
};

const RESPONSES: Record<string, unknown> = {
	"/api/blocklists": BLOCKLISTS,
	"/api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
};

let resolveUpdate: ((response: Response) => void) | null;
let deleted: string[];

beforeEach(() => {
	clearRefreshStatus();
	resolveUpdate = null;
	deleted = [];
	vi.stubGlobal(
		"fetch",
		vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
			const url = String(input);
			if (init?.method === "DELETE") {
				deleted.push(url);
				return new Response(null, { status: 204 });
			}
			if (url === "/api/blocklists/update" && init?.method === "POST") {
				return new Promise<Response>((resolve) => {
					resolveUpdate = resolve;
				});
			}
			const payload = RESPONSES[url];
			if (payload === undefined) return new Response(JSON.stringify({ error: "not stubbed" }), { status: 404 });
			return new Response(JSON.stringify(payload), {
				status: 200,
				headers: { "content-type": "application/json" },
			});
		}),
	);
});

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

function renderBlocklistsRoute(queryClient = createQueryClient()) {
	const router = createAppRouter(createMemoryHistory({ initialEntries: ["/blocklists"] }), queryClient);
	const view = render(
		<AuthProvider>
			<QueryClientProvider client={queryClient}>
				<RouterProvider router={router} />
			</QueryClientProvider>
		</AuthProvider>,
	);
	return { queryClient, unmount: view.unmount };
}

const SNAPSHOT = {
	sources: [
		{
			id: 1,
			state: "loaded",
			loaded: true,
			last_attempt: 1700000100,
			last_success: 1700000100,
			url: "https://example.com/hosts.txt",
			last_error: "",
			domains: 1200,
			wildcards: 12,
			exceptions: 9,
			skipped_regex: 4,
			skipped_unsupported: 17,
		},
	],
};

test("renders the source table and the status empty state", async () => {
	renderBlocklistsRoute();
	await screen.findByRole("heading", { name: "Blocklists" });

	expect(screen.getByText("StevenBlack")).toBeTruthy();
	expect(screen.getByText("https://example.com/hosts.txt")).toBeTruthy();
	expect(screen.getByText("Suggested")).toBeTruthy();
	expect(screen.getByText("1000")).toBeTruthy();
	expect(screen.getByText("10")).toBeTruthy();
	expect(screen.getByText("7")).toBeTruthy();
	expect(screen.getByText("3")).toBeTruthy();
	expect(screen.getByText("21")).toBeTruthy();
	expect(screen.getByText("never")).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();

	const enabledToggle = screen.getByLabelText("StevenBlack enabled") as HTMLInputElement;
	expect(enabledToggle.checked).toBe(true);
	const disabledToggle = screen.getByLabelText("Custom enabled") as HTMLInputElement;
	expect(disabledToggle.checked).toBe(false);

	expect(screen.getByText(/run .Update now. to fetch status/)).toBeTruthy();
	expect(screen.getByRole("heading", { name: "Add source" })).toBeTruthy();
});

test("update now disables the button, then replaces the status section from the 202 snapshot", async () => {
	renderBlocklistsRoute();
	await screen.findByRole("heading", { name: "Blocklists" });

	const button = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
	fireEvent.click(button);

	const pending = (await screen.findByRole("button", { name: "Updating…" })) as HTMLButtonElement;
	expect(pending.disabled).toBe(true);
	expect(resolveUpdate).not.toBeNull();

	const snapshot = {
		sources: [
			{
				id: 1,
				state: "loaded",
				loaded: true,
				last_attempt: 1700000100,
				last_success: 1700000100,
				url: "https://example.com/hosts.txt",
				last_error: "",
				domains: 1200,
				wildcards: 12,
				exceptions: 9,
				skipped_regex: 4,
				skipped_unsupported: 17,
			},
			{
				id: 2,
				state: "fetch_failed",
				loaded: false,
				last_attempt: 1700000100,
				last_success: 0,
				url: "https://example.org/list.txt",
				last_error: "connect timed out",
				domains: 0,
				wildcards: 0,
				exceptions: 0,
				skipped_regex: 0,
				skipped_unsupported: 0,
			},
		],
	};
	resolveUpdate!(
		new Response(JSON.stringify(snapshot), { status: 202, headers: { "content-type": "application/json" } }),
	);

	await screen.findByText("loaded");
	expect(screen.getByText("fetch_failed")).toBeTruthy();
	expect(screen.getByText("connect timed out")).toBeTruthy();
	expect(screen.getByText("1200")).toBeTruthy();
	expect(screen.getByText("12")).toBeTruthy();
	expect(screen.getByText("9")).toBeTruthy();
	expect(screen.getByText("4")).toBeTruthy();
	expect(screen.getByText("17")).toBeTruthy();
	expect(screen.getAllByRole("columnheader", { name: "Skipped unsupported" })).toHaveLength(2);
	expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
	// The store notifies one flush before the mutation's success state lands.
	await screen.findByText(/Update completed/);

	await waitFor(() => {
		const idle = screen.getByRole("button", { name: "Update now" }) as HTMLButtonElement;
		expect(idle.disabled).toBe(false);
	});
});

test("update now shows a countdown when rate limited with Retry-After", async () => {
	renderBlocklistsRoute();
	await screen.findByRole("heading", { name: "Blocklists" });

	fireEvent.click(screen.getByRole("button", { name: "Update now" }));
	await screen.findByRole("button", { name: "Updating…" });
	expect(resolveUpdate).not.toBeNull();

	resolveUpdate!(
		new Response(JSON.stringify({ error: "rate limited" }), {
			status: 429,
			headers: { "content-type": "application/json", "Retry-After": "7" },
		}),
	);

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

test("the refresh snapshot outlives the query cache's gcTime", async () => {
	vi.useFakeTimers({ shouldAdvanceTime: true });
	try {
		const { queryClient, unmount } = renderBlocklistsRoute();
		await screen.findByRole("heading", { name: "Blocklists" });

		fireEvent.click(screen.getByRole("button", { name: "Update now" }));
		await screen.findByRole("button", { name: "Updating…" });
		resolveUpdate!(
			new Response(JSON.stringify(SNAPSHOT), {
				status: 202,
				headers: { "content-type": "application/json" },
			}),
		);
		await screen.findByText("loaded");

		unmount();
		// Well past the default 5-minute gcTime: an unsubscribed cache entry is
		// collected by now, which is what used to erase the snapshot.
		await act(async () => {
			await vi.advanceTimersByTimeAsync(6 * 60_000);
		});

		renderBlocklistsRoute(queryClient);
		await screen.findByRole("heading", { name: "Blocklists" });
		expect(await screen.findByText("loaded")).toBeTruthy();
		expect(screen.queryByText(/run .Update now. to fetch status/)).toBeNull();
	} finally {
		vi.useRealTimers();
	}
});

test("delete asks for confirmation, and cancelling sends no request", async () => {
	renderBlocklistsRoute();
	await screen.findByRole("heading", { name: "Blocklists" });

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

	fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
	await waitFor(() => expect(screen.queryByRole("alertdialog")).toBeNull());
	expect(deleted).toEqual([]);
});

test("confirming the delete dialog issues the DELETE for that source", async () => {
	renderBlocklistsRoute();
	await screen.findByRole("heading", { name: "Blocklists" });

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

	await waitFor(() => expect(deleted).toEqual(["/api/blocklists/2"]));
});
