import { getToken, clearAuth } from './auth';

const BASE = '/api/v1';

export class ApiError extends Error {
	status: number;
	code: string;

	constructor(status: number, code: string, message: string) {
		super(message);
		this.status = status;
		this.code = code;
	}
}

const TIMEOUT_MS = 20_000;

/**
 * Pages that ARE a credential-entry form — redirecting away from them would loop.
 *
 * `/admin/login` belongs here: a mistyped admin password 401s, the hard redirect below fired, and
 * the admin was thrown off their own login form — destroying the error message that would have
 * told them what happened. The redirect exists to rescue a session that died mid-app; someone
 * actively typing credentials into a login form does not need rescuing.
 *
 * It must be `/admin/login` and NOT the `/admin` prefix. `/admin` would also match the dashboard,
 * and suppressing `clearAuth()` there is a trap: the dead token stays resident, `admin/+page`
 * bounces to `/admin/login`, and that page's `getRole() === 'admin'` guard — which decodes the JWT
 * without checking `exp` — bounces straight back. The result is an unbreakable flip-flop with no
 * way to reach the login form short of clearing site data, on the one device running the party.
 */
const AUTH_ROUTES = ['/join', '/recover', '/admin/login'];

/**
 * Send someone whose session died back to the right credential form.
 *
 * Staff go to `/admin/login`, guests to `/join`. Sending an admin whose session expired to the
 * guest join screen is a dead end: they do not have a name and PIN to type, and the screen gives
 * them no route back to their own login.
 *
 * Deliberately uses `window.location` rather than SvelteKit's `goto`: `toast-store` already
 * imports `ApiError` from this module, so pulling a store or `$app/navigation` in here would
 * create an import cycle. A full document load is also the more correct behaviour after a
 * session loss — it resets every module-level store, which is exactly what we want, and the
 * queued upload blobs live in IndexedDB so they survive it.
 */
function redirectToJoin(): void {
	if (typeof window === 'undefined') return;
	if (onAuthRoute()) return;
	const staff = window.location.pathname.startsWith('/admin');
	window.location.assign(staff ? '/admin/login' : '/join');
}

/**
 * Are we currently ON a credential-entry page?
 *
 * A 401 means two completely different things depending on the answer, and treating them alike
 * destroyed data (H14). Off these pages it means "your session died" — clear it and rescue the
 * guest. ON them it means "the credentials you just typed were wrong", which is ordinary form
 * validation and must change no stored state at all.
 *
 * The concrete failure: an authenticated guest taps "Gerät wechseln", mistypes their own name, and
 * the backend answers 401 (deliberately identical for a wrong PIN and an unknown name — that
 * indistinguishability is an anti-enumeration property, see `recover`'s dummy bcrypt, so it must not
 * be "fixed" by making the responses differ). `clearAuth()` then threw away their working token,
 * and the recover page additionally called `clearPin()` — which discarded the ONLY copy of their
 * PIN, since localStorage is where it lives and the server holds only the bcrypt. One typo, and
 * both their session and their credential were gone; rejoining under the same name 409s.
 */
function onAuthRoute(): boolean {
	if (typeof window === 'undefined') return false;
	const path = window.location.pathname;
	return AUTH_ROUTES.some((r) => path === r || path.startsWith(`${r}/`));
}

async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
	const headers: Record<string, string> = {};
	const token = getToken();
	if (token) {
		headers['Authorization'] = `Bearer ${token}`;
	}
	if (body !== undefined) {
		headers['Content-Type'] = 'application/json';
	}

	// Abort hung requests so a dead connection surfaces as a friendly error
	// instead of a spinner that never resolves.
	//
	// The timer must stay armed until the BODY has been read, not just the headers.
	// `fetch` resolves as soon as the response head arrives, so clearing it in a `finally`
	// around the fetch left `res.text()` below completely uncovered — and no longer
	// abortable, since the controller had already been disarmed. An upstream that sends
	// headers and then stalls the body (the shape of a half-dead proxy, or of the pool
	// saturation this same release adds shedding for) hung that call forever.
	const controller = new AbortController();
	const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);

	let res: Response;
	try {
		res = await fetch(`${BASE}${path}`, {
			method,
			headers,
			body: body !== undefined ? JSON.stringify(body) : undefined,
			signal: controller.signal
		});
	} catch (e) {
		clearTimeout(timer);
		if (e instanceof DOMException && e.name === 'AbortError') {
			throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
		}
		throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.');
	}

	if (res.status === 204) {
		// Must clear on this path too, or every no-content request (logout, delete, like)
		// leaks a live 20 s timer.
		clearTimeout(timer);
		return undefined as T;
	}

	// A 5xx behind a proxy (or a crash page) can return HTML, not JSON — parsing
	// it directly would throw an opaque SyntaxError. Read text, parse defensively.
	let raw: string;
	try {
		raw = await res.text();
	} catch (e) {
		if (e instanceof DOMException && e.name === 'AbortError') {
			throw new ApiError(0, 'timeout', 'Zeitüberschreitung – bitte erneut versuchen.');
		}
		throw new ApiError(0, 'network', 'Netzwerkfehler – bitte Verbindung prüfen.');
	} finally {
		clearTimeout(timer);
	}

	let data: { error?: string; message?: string } | unknown = null;
	if (raw) {
		try {
			data = JSON.parse(raw);
		} catch {
			data = null;
		}
	}

	if (!res.ok) {
		// An expired/invalid token (401) clears the dead session. Banned users are
		// NOT logged out — they keep read access by design (USER_JOURNEYS §10) and
		// simply get a 403 "gesperrt" toast on writes.
		if (res.status === 401 && !onAuthRoute()) {
			clearAuth();
			// Clearing auth alone leaves the guest stranded: the bottom nav and FAB are
			// gated on `isAuthenticated` so they simply vanish, route guards only run in
			// onMount (which does not re-run), and a standalone PWA has no URL bar — so
			// there is no way back to /join. Real triggers mid-event are a host PIN reset
			// (which revokes that guest's sessions) and a redeployed JWT_SECRET.
			// Queued upload blobs survive in IndexedDB and are picked up again after
			// re-joining — via the `onSetAuth` hook in upload-queue.ts, NOT the boot-time
			// call in +layout.svelte: this redirect lands on /join with no token, so the
			// layout's `if (getToken())` skips it, and the subsequent recover navigates
			// with `goto()`, which never re-runs `onMount`.
			redirectToJoin();
		}
		const d = (data ?? {}) as { error?: string; message?: string };
		throw new ApiError(
			res.status,
			d.error ?? 'unknown',
			d.message ?? `Serverfehler (${res.status}).`
		);
	}

	return data as T;
}

export const api = {
	get: <T>(path: string) => request<T>('GET', path),
	post: <T>(path: string, body?: unknown) => request<T>('POST', path, body),
	patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body),
	delete: <T>(path: string) => request<T>('DELETE', path)
};
