import { base } from "$app/paths";
import { env } from "$env/dynamic/private";
import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";

export interface RequiredClaim {
	field: string;
	value: string;
}

export interface OAuthConfig {
	clientId: string;
	issuerUrl: string;
	authorizationUrl: string;
	tokenUrl: string;
	scopes: string;
	sessionSecret: string;
	sessionDuration: number;
	allowedSubs?: Set<string>;
	requiredClaim?: RequiredClaim;
}

export interface SessionPayload {
	sub?: string;
	name?: string;
	email?: string;
	exp: number;
}

interface OpenIDConfiguration {
	authorization_endpoint: string;
	token_endpoint: string;
}

interface TokenResponse {
	access_token: string;
	token_type: string;
	expires_in?: number;
	id_token?: string;
	scope?: string;
}

const DEFAULT_SESSION_DURATION = 86400;
export const OAUTH_CIMD_CLIENT_ID = "__CIMD__";

let cachedConfig: OAuthConfig | null | undefined;

function parseSessionDuration(rawDuration: string | undefined): number {
	if (!rawDuration) {
		return DEFAULT_SESSION_DURATION;
	}

	const parsed = Number(rawDuration);
	if (!Number.isInteger(parsed) || parsed <= 0) {
		return DEFAULT_SESSION_DURATION;
	}

	return parsed;
}

async function fetchOpenIDConfiguration(issuerUrl: string): Promise<OpenIDConfiguration> {
	const wellKnown = issuerUrl.replace(/\/+$/, "") + "/.well-known/openid-configuration";
	const response = await fetch(wellKnown);

	if (!response.ok) {
		throw new Error(`Failed to fetch OpenID configuration from ${wellKnown} (${response.status})`);
	}

	const config = await response.json();

	if (!config.authorization_endpoint || !config.token_endpoint) {
		throw new Error(`OpenID configuration at ${wellKnown} is missing required endpoints`);
	}

	return config;
}

/**
 * Returns OAuth config from env vars, or null if not configured.
 * Fetches the OpenID discovery document on first call and caches the result.
 * Throws if partially configured (some required vars missing).
 */
export async function getOAuthConfig(): Promise<OAuthConfig | null> {
	if (cachedConfig !== undefined) {
		return cachedConfig;
	}

	const clientId = env.MONGOKU_OAUTH_CLIENT_ID;
	if (!clientId) {
		cachedConfig = null;
		return null;
	}

	const issuerUrl = env.MONGOKU_OAUTH_ISSUER_URL;
	const sessionSecret = env.MONGOKU_OAUTH_SESSION_SECRET;

	if (!issuerUrl || !sessionSecret) {
		throw new Error(
			"OAuth is partially configured. When MONGOKU_OAUTH_CLIENT_ID is set, " +
				"MONGOKU_OAUTH_ISSUER_URL and MONGOKU_OAUTH_SESSION_SECRET are also required.",
		);
	}

	const oidc = await fetchOpenIDConfiguration(issuerUrl);

	const allowedSubsRaw = env.MONGOKU_OAUTH_ALLOWED_SUBS;
	const allowedSubs = allowedSubsRaw
		? new Set(
				allowedSubsRaw
					.split(",")
					.map((s) => s.trim())
					.filter(Boolean),
			)
		: undefined;

	const requiredClaimRaw = env.MONGOKU_OAUTH_REQUIRED_CLAIM;
	let requiredClaim: RequiredClaim | undefined;
	if (requiredClaimRaw) {
		const eqIndex = requiredClaimRaw.indexOf("=");
		if (eqIndex <= 0) {
			throw new Error('MONGOKU_OAUTH_REQUIRED_CLAIM must be in the format "field=value" (e.g. "authority=admin")');
		}
		requiredClaim = {
			field: requiredClaimRaw.slice(0, eqIndex),
			value: requiredClaimRaw.slice(eqIndex + 1),
		};
	}

	cachedConfig = {
		clientId,
		issuerUrl,
		authorizationUrl: oidc.authorization_endpoint,
		tokenUrl: oidc.token_endpoint,
		scopes: env.MONGOKU_OAUTH_SCOPES ?? "openid profile email",
		sessionSecret,
		sessionDuration: parseSessionDuration(env.MONGOKU_OAUTH_SESSION_DURATION),
		allowedSubs,
		requiredClaim,
	};

	return cachedConfig;
}

function resolveOAuthClientId(config: OAuthConfig, origin: string): string {
	if (config.clientId !== OAUTH_CIMD_CLIENT_ID) {
		return config.clientId;
	}

	return new URL(`${base}/.well-known/cimd.json`, origin).toString();
}

function base64url(buffer: Buffer): string {
	return buffer.toString("base64url");
}

export function generateCodeVerifier(): string {
	return base64url(randomBytes(64));
}

export function generateCodeChallenge(verifier: string): string {
	return base64url(createHash("sha256").update(verifier).digest());
}

export function generateState(): string {
	return base64url(randomBytes(32));
}

export function buildAuthorizationUrl(
	config: OAuthConfig,
	origin: string,
	callbackUrl: string,
	codeChallenge: string,
	state: string,
): string {
	const url = new URL(config.authorizationUrl);
	url.searchParams.set("client_id", resolveOAuthClientId(config, origin));
	url.searchParams.set("response_type", "code");
	url.searchParams.set("redirect_uri", callbackUrl);
	url.searchParams.set("scope", config.scopes);
	url.searchParams.set("state", state);
	url.searchParams.set("code_challenge", codeChallenge);
	url.searchParams.set("code_challenge_method", "S256");
	return url.toString();
}

export async function exchangeCode(
	config: OAuthConfig,
	origin: string,
	code: string,
	codeVerifier: string,
	callbackUrl: string,
): Promise<TokenResponse> {
	const body = new URLSearchParams({
		grant_type: "authorization_code",
		code,
		redirect_uri: callbackUrl,
		client_id: resolveOAuthClientId(config, origin),
		code_verifier: codeVerifier,
	});

	const response = await fetch(config.tokenUrl, {
		method: "POST",
		headers: { "Content-Type": "application/x-www-form-urlencoded" },
		body: body.toString(),
	});

	if (!response.ok) {
		const text = await response.text();
		throw new Error(`Token exchange failed (${response.status}): ${text}`);
	}

	return response.json();
}

export interface IdTokenResult {
	user: { sub?: string; name?: string; email?: string };
	claims: Record<string, unknown>;
}

/**
 * Extract user info from an OIDC id_token (JWT) without cryptographic verification.
 * This is safe because the token was obtained directly from the token endpoint over HTTPS.
 */
export function extractUserFromIdToken(idToken: string): IdTokenResult {
	try {
		const parts = idToken.split(".");
		if (parts.length !== 3) {
			return { user: {}, claims: {} };
		}
		const claims = JSON.parse(Buffer.from(parts[1], "base64url").toString());
		return {
			user: {
				sub: claims.sub,
				name: claims.name || claims.preferred_username,
				email: claims.email,
			},
			claims,
		};
	} catch {
		return { user: {}, claims: {} };
	}
}

export function checkRequiredClaim(claims: Record<string, unknown>, required: RequiredClaim): boolean {
	const actual = claims[required.field];
	if (Array.isArray(actual)) {
		return actual.includes(required.value);
	}
	return String(actual) === required.value;
}

export function createSessionCookie(
	config: OAuthConfig,
	user: { sub?: string; name?: string; email?: string },
): string {
	const sessionDuration =
		Number.isInteger(config.sessionDuration) && config.sessionDuration > 0
			? config.sessionDuration
			: DEFAULT_SESSION_DURATION;

	const payload: SessionPayload = {
		...user,
		exp: Math.floor(Date.now() / 1000) + sessionDuration,
	};
	const payloadStr = Buffer.from(JSON.stringify(payload)).toString("base64url");
	const signature = createHmac("sha256", config.sessionSecret).update(payloadStr).digest("base64url");
	return `${payloadStr}.${signature}`;
}

export function verifySession(config: OAuthConfig, cookie: string): SessionPayload | null {
	const dotIndex = cookie.lastIndexOf(".");
	if (dotIndex === -1) {
		return null;
	}

	const payloadStr = cookie.slice(0, dotIndex);
	const signature = cookie.slice(dotIndex + 1);

	const expectedSignature = createHmac("sha256", config.sessionSecret).update(payloadStr).digest("base64url");

	const a = Buffer.from(signature);
	const b = Buffer.from(expectedSignature);
	if (a.length !== b.length || !timingSafeEqual(a, b)) {
		return null;
	}

	try {
		const payload: SessionPayload = JSON.parse(Buffer.from(payloadStr, "base64url").toString());
		if (!Number.isFinite(payload.exp)) {
			return null;
		}
		if (payload.exp < Math.floor(Date.now() / 1000)) {
			return null;
		}
		return payload;
	} catch {
		return null;
	}
}

export function getCallbackUrl(origin: string): string {
	return `${origin}${base}/auth/callback`;
}

const OAUTH_AUTH_PREFIX = `${base}/auth`;

/**
 * Validates a post-login redirect target: same origin as the request, under the app base path,
 * and not an OAuth route (avoids redirect loops). Returns pathname + search, or null to use the default home redirect.
 */
export function sanitizeOAuthReturnPath(requestUrl: URL, raw: string | null | undefined): string | null {
	if (raw == null || raw === "") {
		return null;
	}

	const trimmed = raw.trim();
	if (trimmed.startsWith("//")) {
		return null;
	}

	let pathWithSearch: string;
	if (/^https?:\/\//i.test(trimmed)) {
		let parsed: URL;
		try {
			parsed = new URL(trimmed);
		} catch {
			return null;
		}
		if (parsed.origin !== requestUrl.origin) {
			return null;
		}
		pathWithSearch = parsed.pathname + parsed.search;
	} else if (trimmed.startsWith("/")) {
		try {
			const parsed = new URL(trimmed, requestUrl.origin);
			if (parsed.origin !== requestUrl.origin) {
				return null;
			}
			pathWithSearch = parsed.pathname + parsed.search;
		} catch {
			return null;
		}
	} else {
		return null;
	}

	if (base !== "") {
		if (pathWithSearch !== base && !pathWithSearch.startsWith(`${base}/`)) {
			return null;
		}
	} else if (!pathWithSearch.startsWith("/")) {
		return null;
	}

	if (pathWithSearch === OAUTH_AUTH_PREFIX || pathWithSearch.startsWith(`${OAUTH_AUTH_PREFIX}/`)) {
		return null;
	}

	return pathWithSearch;
}

export const OAUTH_RETURN_COOKIE = "mongoku_oauth_return";

export function cookieOptions(url: URL, maxAge?: number) {
	return {
		httpOnly: true,
		secure: url.protocol === "https:",
		sameSite: "lax" as const,
		path: base || "/",
		...(maxAge !== undefined && { maxAge }),
	};
}
