import type { QueryClient } from "@tanstack/react-query";
import * as stylex from "@stylexjs/stylex";
import {
	createRootRouteWithContext,
	createRoute,
	createRouter,
	lazyRouteComponent,
	redirect,
	useRouter,
	type ErrorComponentProps,
	type RouterHistory,
} from "@tanstack/react-router";
import AppShell from "@/shell/AppShell";
import { ApiError } from "@/lib/api";
import { createQueryClient } from "@/lib/queryClient";
import { diagnosticsFilterOf, type DiagnosticsSearch } from "@/features/diagnostics/filter";
import {
	queriesFilterOf,
	validateActivitySearch,
	validateText,
	validateTimestamp,
	type ActivitySearch,
} from "@/features/activity/search";
import {
	blocklistsQuery,
	clientPrefixesQuery,
	clientsQuery,
	configStatusQuery,
	diagnosticQuery,
	diagnosticsInfiniteQuery,
	forwardZonesQuery,
	groupsQuery,
	healthQuery,
	localRecordsQuery,
	overviewQuery,
	queriesInfiniteQuery,
	queryDetailQuery,
	rulesQuery,
	settingsQuery,
	upstreamsQuery,
} from "@/lib/queries";
import { DEFAULT_PERIOD, parsePeriod } from "@/features/overview/period";
import { OverviewPending } from "@/features/overview/OverviewFrame";
import {
	validateGroupId,
	validateProtectionSearch,
	validateResolutionSearch,
	type ProtectionSearch,
	type ResolutionSearch,
} from "@/features/configuration/search";
import type { Period } from "@/lib/types";
import { styles as shared } from "@/ui/styles";
import { colors } from "@/ui/tokens.stylex";

export interface RouterContext {
	queryClient: QueryClient;
}

const styles = stylex.create({
	pending: {
		padding: "2rem",
		textAlign: "center",
		color: colors.textMuted,
	},
	errorBox: {
		margin: "1rem",
		borderRadius: "0.25rem",
		borderWidth: 1,
		borderStyle: "solid",
		borderColor: colors.dangerBorder,
		backgroundColor: colors.dangerSurface,
		padding: "1rem",
	},
	errorTitle: {
		fontWeight: 600,
		color: colors.dangerText,
	},
	errorDetail: {
		marginTop: "0.25rem",
		fontSize: "0.875rem",
		lineHeight: "1.25rem",
		color: colors.dangerText,
	},
});

function RoutePending() {
	return (
		<div {...stylex.props(styles.pending)} role="status">
			<span {...stylex.props(shared.pulse)}>Loading…</span>
		</div>
	);
}

function RouteError({ error }: ErrorComponentProps) {
	const router = useRouter();
	let title = "Something went wrong";
	let detail = error.message;
	if (error instanceof ApiError) {
		if (error.status === 503) {
			title = "Server starting or degraded";
			detail = error.message;
		} else if (error.status === 429) {
			title = "Rate limited";
			detail = error.retryAfter !== undefined ? `Try again in ${error.retryAfter}s.` : "Try again shortly.";
		} else if (error.status >= 500) {
			title = "Internal error";
		} else {
			title = `Request failed (${error.status})`;
		}
	}
	return (
		<div role="alert" {...stylex.props(styles.errorBox)}>
			<h2 {...stylex.props(styles.errorTitle)}>{title}</h2>
			<p {...stylex.props(styles.errorDetail)}>{detail}</p>
			<button
				type="button"
				onClick={() => void router.invalidate()}
				{...stylex.props(shared.retryButton, shared.focusRing)}
			>
				Retry
			</button>
		</div>
	);
}

const rootRoute = createRootRouteWithContext<RouterContext>()();

const loginRoute = createRoute({
	getParentRoute: () => rootRoute,
	path: "/login",
	validateSearch: (search: Record<string, unknown>): { redirect?: string } => ({
		redirect: typeof search["redirect"] === "string" ? search["redirect"] : undefined,
	}),
	component: lazyRouteComponent(() => import("@/auth/LoginPage")),
});

const shellRoute = createRoute({
	getParentRoute: () => rootRoute,
	id: "shell",
	component: AppShell,
});

/**
 * The landing default, not a compatibility alias: Overview is where the app
 * opens, and `/` is spelled out rather than left as a second name for it.
 */
const indexRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/",
	beforeLoad: () => {
		throw redirect({ to: "/overview" });
	},
});

/**
 * Overview. The period is the whole of its applied state, so a view of the page
 * is a link: a hand-typed or stale value falls back to the default rather than
 * reaching the API as a parameter it answers 400 to.
 */
const overviewRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/overview",
	validateSearch: (search: Record<string, unknown>): { period?: Period } => ({
		period: parsePeriod(search["period"]),
	}),
	loaderDeps: ({ search }): { period: Period } => ({ period: search.period ?? DEFAULT_PERIOD }),
	/**
	 * Started here, awaited nowhere. The page reads these with `useQuery` and owns
	 * its own loading and error surface, so awaiting would trade that contract for
	 * one blocking navigation: nothing at all until the request answered, rather
	 * than the heading and the period picker while it is in flight. The rejections
	 * are caught only to keep them from going unhandled; the page states them.
	 */
	loader: ({ context, deps }) => {
		const start = (promise: Promise<unknown>) => void promise.catch(() => {});
		start(context.queryClient.ensureQueryData(healthQuery()));
		start(context.queryClient.ensureQueryData(overviewQuery(deps.period)));
		// The registered names the client chart labels its series with. Started here
		// so the lookup is not a second round trip after the page chunk lands.
		start(context.queryClient.ensureQueryData(clientsQuery()));
	},
	component: lazyRouteComponent(() => import("@/features/overview/OverviewPage")),
	/**
	 * The page's own loading surface, rendered while its chunk is still in flight.
	 * The default pending component would put a second, differently-placed
	 * "Loading…" before it, which reads as a stutter rather than one wait.
	 * `OverviewFrame` is a separate module so this import leaves the charts lazy.
	 */
	pendingComponent: OverviewPending,
});

/**
 * Activity. The URL is the applied state: mode, the five filters, and nothing
 * else. Everything is validated by `activity/search.ts`, so a hand-typed or
 * stale parameter becomes `undefined` here rather than reaching the API as a
 * value it answers 400 to.
 */
const activityRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/activity",
	validateSearch: validateActivitySearch,
	// An explicit projection, not `search` itself: the router hands the loader
	// whatever else the URL carried, and an unknown key would make two
	// otherwise-identical loads look like different deps.
	loaderDeps: ({ search }): ActivitySearch => ({
		mode: search.mode,
		since: search.since,
		until: search.until,
		domain: search.domain,
		client: search.client,
		blocked: search.blocked,
	}),
	/**
	 * Starts the first page in parallel with the component chunk, and does not
	 * wait for it. Awaiting would make every Apply a blocking navigation, which
	 * throws away the `keepPreviousData` placeholder the list is built on: the
	 * reader would lose the rows they were reading to a pending page instead of
	 * watching them be replaced. The page owns the loading and error surfaces,
	 * so the rejection is caught here only to keep it from going unhandled.
	 *
	 * Live mode reads the SSE stream and nothing else. Prefetching the log for
	 * it would spend a request per navigation on rows the page never renders,
	 * with the retained filters attached to make it look deliberate.
	 */
	loader: ({ context, deps }) => {
		if (deps.mode !== "history") return;
		void context.queryClient.ensureInfiniteQueryData(queriesInfiniteQuery(queriesFilterOf(deps))).catch(() => {});
	},
	component: lazyRouteComponent(() => import("@/features/activity/ActivityPage")),
});

/**
 * One logged query. Its search is the Activity search the reader arrived from,
 * validated by the same functions, so the back link and every related action
 * restore the exact investigation instead of a default view of it.
 */
const activityDetailRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/activity/queries/$id",
	validateSearch: validateActivitySearch,
	// Swallowed on purpose, as the diagnostics detail route does: a row
	// retention has pruned is a 404 the page explains, with the way back to the
	// log. The whole-page error component would call it a request failure.
	loader: ({ context, params }) =>
		context.queryClient.ensureQueryData(queryDetailQuery(Number(params.id))).catch(() => undefined),
	component: lazyRouteComponent(() => import("@/features/activity/ActivityDetailPage")),
});

/** `domain` prefills and runs the simulation, so a query detail can link into it. */
const activityTestRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/activity/test",
	validateSearch: (search: Record<string, unknown>): { domain?: string } => ({
		domain: validateText(search["domain"]),
	}),
	loader: ({ context }) => context.queryClient.ensureQueryData(groupsQuery()),
	component: lazyRouteComponent(() => import("@/features/activity/PolicyTestPage")),
});

const clientsRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/clients",
	// `group` is the filter Protection's client count links into. Validated here
	// so a hand-typed id cannot reach the page as anything but a row id.
	validateSearch: (search: Record<string, unknown>): { group?: number } => ({
		group: validateGroupId(search["group"]),
	}),
	loader: ({ context }) =>
		Promise.all([
			context.queryClient.ensureQueryData(clientsQuery()),
			context.queryClient.ensureQueryData(clientPrefixesQuery()),
			context.queryClient.ensureQueryData(groupsQuery()),
		]),
	component: lazyRouteComponent(() => import("@/features/clients/ClientsPage")),
});

/**
 * One client, keyed by the row id the client API already identifies it with.
 *
 * No endpoint answers for a single client, so the list is the source. It is
 * awaited, as the other detail routes await theirs: a cold deep link has
 * nothing to render until it lands, and holding the navigation for one request
 * beats a page that flashes "no such client" before the rows arrive. The
 * rejection is swallowed for the same reason theirs are — the page states a
 * missing row and a failed fetch itself, where the whole-page error component
 * would call both a request failure.
 */
const clientDetailRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/clients/$id",
	loader: ({ context }) => context.queryClient.ensureQueryData(clientsQuery()).catch(() => undefined),
	component: lazyRouteComponent(() => import("@/features/clients/ClientDetailPage")),
});

/**
 * The filters and the window live in the url so an episode can be linked to as
 * it was read — a query detail links here with an absolute five-minute window
 * around one query, which only means anything if the page applies it. Anything
 * else in the search object is dropped: an unknown value would reach the api as
 * a query parameter the handler rejects with a 400.
 */
const diagnosticsRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/diagnostics",
	validateSearch: (search: Record<string, unknown>): DiagnosticsSearch => {
		const state = search["state"];
		const severity = search["severity"];
		return {
			state: state === "active" || state === "resolved" ? state : undefined,
			severity: severity === "warning" || severity === "error" ? severity : undefined,
			component: validateText(search["component"]),
			since: validateTimestamp(search["since"]),
			until: validateTimestamp(search["until"]),
		};
	},
	loaderDeps: ({ search }): DiagnosticsSearch => ({
		state: search.state,
		severity: search.severity,
		component: search.component,
		since: search.since,
		until: search.until,
	}),
	/**
	 * Started, not awaited, as Overview's is: the strip and the two lists each
	 * render their own loading and error state, and the health strip's whole
	 * contract begins with a visible loading state it would never reach if the
	 * route held the page back until the reading arrived.
	 */
	loader: ({ context, deps }) => {
		const base = diagnosticsFilterOf(deps);
		void context.queryClient.ensureQueryData(healthQuery()).catch(() => {});
		for (const state of ["active", "resolved"] as const) {
			void context.queryClient
				.ensureInfiniteQueryData(diagnosticsInfiniteQuery({ ...base, state }))
				.catch(() => {});
		}
	},
	component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticsPage")),
});

const diagnosticDetailRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/diagnostics/$id",
	// Swallowed on purpose: an event retention has removed is a 404 the page
	// itself explains, with the way back to the list. The whole-page error
	// component would state it as a request failure instead.
	loader: ({ context, params }) =>
		context.queryClient.ensureQueryData(diagnosticQuery(Number(params.id))).catch(() => undefined),
	component: lazyRouteComponent(() => import("@/features/diagnostics/DiagnosticDetailPage")),
});

/**
 * The three task-shaped configuration pages. There is no `/configuration`
 * landing route: a bare `/configuration` is not a question anyone has, and a
 * route that only redirects is a second name for a page.
 *
 * Every loader here is started and awaited nowhere, the pattern the rest of the
 * app uses: each panel owns its loading and error surface, so awaiting would
 * trade that for one blocking navigation on the slowest request. The rejections
 * are caught only to keep them from going unhandled.
 *
 * `configStatusQuery` starts with all of them. Every configuration page renders
 * nothing — neither form nor definition list — until it answers, so it is on the
 * critical path of all three.
 */
function startConfiguration(queryClient: QueryClient, queries: readonly unknown[]): void {
	const start = (promise: Promise<unknown>) => void promise.catch(() => {});
	start(queryClient.ensureQueryData(configStatusQuery()));
	for (const options of queries) {
		start(queryClient.ensureQueryData(options as Parameters<QueryClient["ensureQueryData"]>[0]));
	}
}

const protectionRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/configuration/protection",
	validateSearch: validateProtectionSearch,
	loaderDeps: ({ search }): ProtectionSearch => ({ tab: search.tab, group: search.group }),
	// Clients rides along because the selected group's detail counts them.
	loader: ({ context }) =>
		startConfiguration(context.queryClient, [groupsQuery(), blocklistsQuery(), rulesQuery(), clientsQuery()]),
	component: lazyRouteComponent(() => import("@/features/configuration/ProtectionPage")),
});

const resolutionRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/configuration/resolution",
	validateSearch: validateResolutionSearch,
	loaderDeps: ({ search }): ResolutionSearch => ({ tab: search.tab }),
	loader: ({ context }) =>
		startConfiguration(context.queryClient, [upstreamsQuery(), localRecordsQuery(), forwardZonesQuery()]),
	component: lazyRouteComponent(() => import("@/features/configuration/ResolutionPage")),
});

const systemRoute = createRoute({
	getParentRoute: () => shellRoute,
	path: "/configuration/system",
	loader: ({ context }) => startConfiguration(context.queryClient, [settingsQuery()]),
	component: lazyRouteComponent(() => import("@/features/configuration/SystemPage")),
});

const routeTree = rootRoute.addChildren([
	loginRoute,
	shellRoute.addChildren([
		indexRoute,
		overviewRoute,
		activityRoute,
		activityDetailRoute,
		activityTestRoute,
		clientsRoute,
		clientDetailRoute,
		diagnosticsRoute,
		diagnosticDetailRoute,
		protectionRoute,
		resolutionRoute,
		systemRoute,
	]),
]);

export function createAppRouter(history?: RouterHistory, queryClient: QueryClient = createQueryClient()) {
	return createRouter({
		routeTree,
		history,
		context: { queryClient },
		defaultPreload: "intent",
		defaultPreloadStaleTime: 0,
		scrollRestoration: true,
		scrollToTopSelectors: ["#main-content"],
		defaultPendingComponent: RoutePending,
		defaultErrorComponent: RouteError,
	});
}

declare module "@tanstack/react-router" {
	interface Register {
		router: ReturnType<typeof createAppRouter>;
	}
}
