/**
 * The fixtures and the stubbed API the configuration tests share.
 *
 * All three pages read the same handful of collections plus
 * `/api/config/status`, so a per-file copy of the stub would be five copies of
 * one contract drifting apart. Test-only: nothing in `src` imports it, so it
 * never reaches the bundle.
 */

import { render, screen } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { RouterProvider, createMemoryHistory } from "@tanstack/react-router";
import { AuthProvider } from "@/auth/store";
import { health } from "@/lib/healthFixture";
import { createQueryClient } from "@/lib/queryClient";
import { createAppRouter } from "@/routes";
import { sample_get_settings } from "@/lib/contractSamples.gen";
import type { ConfigStatus, Settings } from "@/lib/types";

export const CONFIG_PATH = "/etc/nxdns/config.zon";
export const RECONCILED_AT = 1754899200;

export const DATABASE: ConfigStatus = {
	authority: "database",
	path: null,
	reconciled_at: null,
	restart_pending: false,
};

export const MANAGED_FILE: ConfigStatus = {
	authority: "managed_file",
	path: CONFIG_PATH,
	reconciled_at: RECONCILED_AT,
	restart_pending: false,
};

/**
 * The keys `/api/settings` still reports as restart-required, taken from the
 * committed contract sample so a server-side change to the set fails the tests
 * that pin it rather than passing against a stale copy.
 */
export const RESTART_REQUIRED_KEYS: readonly string[] = sample_get_settings.restart_required;

export function baseSettings(): Settings {
	return {
		upstream: { attempt_timeout_ms: 2500, read_timeout_ms: 3000, total_timeout_ms: 5000 },
		dns: { bind_ipv4: "0.0.0.0", bind_ipv6: "::", port: 53, rate_limit: 100, rate_window_seconds: 60 },
		blocking: { response: "zero", ttl: 300 },
		cache: { size: 10000, negative_ttl_max: 300 },
		web: {
			enabled: true,
			bind: "127.0.0.1",
			port: 8080,
			session_ttl_hours: 24,
			api_rate_limit_per_min: 60,
			api_localhost_exempt: true,
			sse_max_connections_per_ip: 2,
			trusted_proxies: "",
			auth_enabled: true,
		},
		doh_server: { enabled: false, bind: "0.0.0.0", port: 443, cert_path: "", key_path: "" },
		dot_server: { enabled: false, bind: "0.0.0.0", port: 853, cert_path: "", key_path: "" },
		edns: { ecs_mode: "strip" },
		logging: {
			level: "info",
			retention_days: 30,
			query_log_buffer_max: 10000,
			query_log_flush_interval_s: 60,
			hide_domains: false,
			hide_client_ips: false,
			output: "stderr",
			file_path: "",
			max_size_mb: 50,
			max_files: 3,
		},
		disk: { min_free_mb: 100, warn_free_mb: 500 },
		blocklist_update: { enabled: true, interval_hours: 24 },
	};
}

export const GROUPS = [
	{ id: 1, name: "default", safe_search: false },
	{ id: 2, name: "kids", safe_search: true },
];

export const BLOCKLISTS = [
	{
		id: 1,
		url: "https://example.com/ads.txt",
		name: "Ads",
		enabled: true,
		is_suggested: false,
		last_updated: null,
		domain_count: 100,
		wildcard_count: 2,
		exception_count: 1,
		skipped_regex_count: 0,
		skipped_unsupported_count: 0,
		checksum: null,
	},
	{
		id: 2,
		url: "https://example.com/trackers.txt",
		name: "Trackers",
		enabled: false,
		is_suggested: true,
		last_updated: 1700000000,
		domain_count: 50,
		wildcard_count: 0,
		exception_count: 0,
		skipped_regex_count: 3,
		skipped_unsupported_count: 4,
		checksum: null,
	},
];

export const RULES = [
	{
		id: 1,
		group_id: 1,
		group: "default",
		pattern: "ads.example.com",
		kind: "exact",
		action: "block",
		created_at: 1700000000,
	},
	{
		id: 2,
		group_id: 2,
		group: "kids",
		pattern: "*.social.example",
		kind: "wildcard",
		action: "block",
		created_at: 1700000100,
	},
];

export const CLIENTS = [
	{
		id: 1,
		ip: "192.168.1.10",
		name: "laptop",
		learned_name: "",
		group_id: 1,
		group: "default",
		hand_edited: true,
		first_seen: 1700000000,
		last_seen: 1700003600,
	},
	{
		id: 2,
		ip: "192.168.1.11",
		name: "",
		learned_name: "tablet.lan",
		group_id: 2,
		group: "kids",
		hand_edited: false,
		first_seen: 1700000000,
		last_seen: 1700007200,
	},
];

export const UPSTREAMS = [
	{ id: 1, url: "udp://1.1.1.1:53", priority: 100, enabled: true, tls_name: "" },
	{ id: 2, url: "tls://9.9.9.9:853", priority: 200, enabled: false, tls_name: "dns.quad9.net" },
];

export const LOCAL_RECORDS = [{ id: 1, name: "nas.lan.home", rtype: "A", value: "192.168.1.5", ttl: 300 }];

export const FORWARD_ZONES = [{ id: 1, zone: "lan.home", resolver: "udp://192.168.1.1:53" }];

export interface Call {
	url: string;
	method: string;
	body: unknown;
}

export interface StubOptions {
	/**
	 * Overrides and additions, keyed `"GET /api/x"`. A `Response` is used
	 * verbatim; a function is called per request, so a test can change what the
	 * server says between two reads of the same endpoint.
	 */
	responses?: Record<string, unknown>;
	/**
	 * First refusal on every non-GET; return null to fall through to the default
	 * echo. An unsettled promise holds the mutation in flight, which is how a
	 * test observes a pending control.
	 */
	onWrite?: (call: Call) => Response | Promise<Response> | null;
}

function defaultResponses(status: ConfigStatus): Record<string, unknown> {
	return {
		"GET /api/config/status": status,
		"GET /api/health": health(),
		"GET /api/version": { version: "0.0.0-test", git_commit: "0000000", zig_version: "0.16.0", uptime_seconds: 1 },
		"GET /api/groups": { groups: GROUPS },
		"GET /api/groups/1/sources": { source_ids: [1] },
		"GET /api/groups/2/sources": { source_ids: [] },
		"GET /api/blocklists": { blocklists: BLOCKLISTS },
		"GET /api/rules": { rules: RULES },
		"GET /api/clients": { clients: CLIENTS },
		"GET /api/client-prefixes": { client_prefixes: [] },
		"GET /api/upstreams": { upstreams: UPSTREAMS },
		"GET /api/local-records": { local_records: LOCAL_RECORDS },
		"GET /api/forward-zones": { forward_zones: FORWARD_ZONES },
		"GET /api/settings": { settings: baseSettings(), restart_required: RESTART_REQUIRED_KEYS },
	};
}

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

/** Installs the stub and returns the list every non-GET request is appended to. */
export function stubApi(status: ConfigStatus = DATABASE, options: StubOptions = {}): Call[] {
	const map = { ...defaultResponses(status), ...options.responses };
	const calls: Call[] = [];
	vi.stubGlobal(
		"fetch",
		vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
			const url = String(input);
			const method = init?.method ?? "GET";
			const body = typeof init?.body === "string" ? JSON.parse(init.body) : undefined;
			const key = `${method} ${url}`;
			const configured = map[key];
			// A promise is handed back unsettled on purpose: it is how a test holds a
			// query in its pending state for as long as it needs to.
			if (configured instanceof Promise) return configured;
			if (configured instanceof Response) return configured.clone();
			if (typeof configured === "function") return json((configured as () => unknown)());
			if (method !== "GET") {
				calls.push({ url, method, body });
				const override = options.onWrite?.({ url, method, body });
				if (override !== null && override !== undefined) return override;
				if (configured !== undefined) return json(configured);
				if (method === "DELETE") return new Response(null, { status: 204 });
				return json(body ?? {});
			}
			if (configured === undefined) return json({ error: `not stubbed: ${key}` }, 404);
			return json(configured);
		}),
	);
	return calls;
}

export function renderRoute(route: string) {
	const queryClient = createQueryClient();
	const router = createAppRouter(createMemoryHistory({ initialEntries: [route] }), queryClient);
	render(
		<AuthProvider>
			<QueryClientProvider client={queryClient}>
				<RouterProvider router={router} />
			</QueryClientProvider>
		</AuthProvider>,
	);
	return router;
}

export async function renderPage(route: string, heading: string) {
	const router = renderRoute(route);
	await screen.findByRole("heading", { name: heading, level: 1 });
	return router;
}

/** The configuration page's own content, excluding the shell chrome around it. */
export function contentArea(): HTMLElement {
	const main = document.querySelector("main");
	if (main === null) throw new Error("no main element");
	return main;
}
