import { browser } from "$app/environment";
import {
	CompositeDidDocumentResolver,
	LocalActorResolver,
	PlcDidDocumentResolver,
	WebDidDocumentResolver,
	XrpcHandleResolver
} from "@atcute/identity-resolver";
import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax";
import {
	OAuthUserAgent,
	configureOAuth,
	createAuthorizationUrl,
	deleteStoredSession,
	finalizeAuthorization,
	getSession,
	listStoredSessions
} from "@atcute/oauth-browser-client";
import { getContext } from "svelte";
import { SvelteURL, SvelteURLSearchParams } from "svelte/reactivity";
import oauthMetadata from "./oauth-client-metadata.json";
import {
	type AuthAccount,
	clearActive,
	dropAccount,
	loadAccounts,
	persistActive,
	readActiveDid,
	reconcileAccounts,
	saveAccounts,
	upsertAccount
} from "./auth/accounts";

export const AUTH_KEY = Symbol("auth");
const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback";
const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`;

// local dev (localinfra) points these at the local pds/plc; defaults are the public network.
const HANDLE_RESOLVER_URL =
	(import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, "") ??
	"https://public.api.bsky.app";
const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace(
	/\/+$/,
	""
);

const identityResolver = new LocalActorResolver({
	handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLVER_URL }),
	didDocumentResolver: new CompositeDidDocumentResolver({
		methods: {
			plc: new PlcDidDocumentResolver(PLC_DIRECTORY_URL ? { apiUrl: PLC_DIRECTORY_URL } : {}),
			web: new WebDidDocumentResolver()
		}
	})
});

let configured = false;

export interface AuthProfile {
	did: Did;
	handle: string;
}

export interface CurrentUser {
	did: Did;
	handle: string;
}

export type AuthState =
	| { kind: "logged-out" }
	| { kind: "loading"; did: Did | null; profile: AuthProfile | null }
	| { kind: "authenticating" }
	| { kind: "profile-loading"; agent: OAuthUserAgent; did: Did }
	| { kind: "authenticated"; agent: OAuthUserAgent; profile: AuthProfile }
	| {
			kind: "failed";
			message: string;
			agent?: OAuthUserAgent;
			did?: Did;
			profile?: AuthProfile;
	  };

export type { AuthAccount } from "./auth/accounts";

export interface Auth {
	readonly state: AuthState;
	readonly agent: OAuthUserAgent | null;
	readonly currentDid: Did | null;
	readonly profile: AuthProfile | null;
	readonly error: string | null;
	readonly profileLoading: boolean;
	readonly authenticating: boolean;
	readonly currentUser: CurrentUser | null;
	readonly accounts: AuthAccount[];
	bobbinUrl: string;
	refresh(): Promise<void>;
	signIn(identifier: string, returnTo?: string): Promise<void>;
	addAccount(identifier: string, returnTo?: string): Promise<void>;
	completeSignIn(): Promise<string>;
	switchAccount(did: Did): Promise<void>;
	removeAccount(did: Did): Promise<void>;
	signOut(): Promise<void>;
	signOutAll(): Promise<void>;
}

type MiniDoc = {
	did: Did;
	handle: string;
	pds?: string;
};

type OAuthSession = ConstructorParameters<typeof OAuthUserAgent>[0];

const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined;
const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes("://localhost") ?? false;
const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT
	? DEV_CLIENT_ID
	: ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID);
const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT
	? DEV_REDIRECT_URI
	: (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI);
const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope;

const configure = () => {
	if (!browser || configured) return;

	configureOAuth({
		metadata: {
			client_id: OAUTH_CLIENT_ID,
			redirect_uri: OAUTH_REDIRECT_URI
		},
		identityResolver
	});

	configured = true;
};

const errorMessage = (cause: unknown) => {
	const message = cause instanceof Error ? cause.message : String(cause);
	return message.toLowerCase().includes("unknown state")
		? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost."
		: message;
};

const resolveProfile = async (
	identifier: string,
	bobbinUrl: string
): Promise<AuthProfile | null> => {
	try {
		const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl);
		url.searchParams.set("identifier", identifier);
		const response = await fetch(url, { headers: { accept: "application/json" } });
		if (response.ok) {
			const profile = (await response.json()) as MiniDoc;
			return {
				did: profile.did,
				handle: profile.handle
			};
		}
	} catch {
		// try local resolver next.
	}

	try {
		const identity = await identityResolver.resolve(identifier as ActorIdentifier);
		return {
			did: identity.did as Did,
			handle: identity.handle
		};
	} catch {
		return null;
	}
};

const returnToFromState = (state: object | null): string => {
	if (state && typeof state === "object" && "returnTo" in state) {
		const returnTo = state.returnTo;
		if (typeof returnTo === "string") return returnTo;
	}
	return "/";
};

export const createAuth = (
	bobbinUrl: string,
	initial?: { did: string; handle: string } | null
): Auth => {
	const seed = initial ?? null;
	const bobbinUrlValue = bobbinUrl;
	let state = $state<AuthState>(
		seed
			? {
					kind: "loading",
					did: seed.did as Did,
					profile: { did: seed.did as Did, handle: seed.handle }
				}
			: { kind: "logged-out" }
	);
	let accounts = $state<AuthAccount[]>([]);

	// merge atcute's stored sessions with persisted account metadata.
	const syncAccounts = () => {
		accounts = reconcileAccounts(browser ? listStoredSessions() : [], loadAccounts());
		saveAccounts(accounts);
	};

	const currentAgent = (): OAuthUserAgent | null =>
		"agent" in state ? (state.agent ?? null) : null;
	const currentDid = (): Did | null =>
		"did" in state
			? (state.did ?? null)
			: state.kind === "authenticated"
				? state.profile.did
				: null;
	const currentProfile = (): AuthProfile | null =>
		"profile" in state ? (state.profile ?? null) : null;
	const failureState = (message: string): AuthState => ({
		kind: "failed",
		message,
		agent: currentAgent() ?? undefined,
		did: currentDid() ?? undefined,
		profile: currentProfile() ?? undefined
	});

	const resetLoggedOut = (message?: string) => {
		clearActive();
		state = message ? { kind: "failed", message } : { kind: "logged-out" };
		syncAccounts();
	};

	const hydrateProfile = async (did: Did, nextAgent: OAuthUserAgent) => {
		const resolved = await resolveProfile(did, bobbinUrl);
		if (state.kind !== "profile-loading" || state.agent !== nextAgent) return;
		const nextProfile = resolved ?? { did, handle: did };
		state = { kind: "authenticated", agent: nextAgent, profile: nextProfile };
		const meta = upsertAccount(loadAccounts(), {
			did,
			handle: nextProfile.handle,
			addedAt: Math.floor(Date.now() / 1000)
		});
		saveAccounts(meta);
		accounts = reconcileAccounts(listStoredSessions(), meta);
		persistActive(did, nextProfile.handle);
	};

	const adoptSession = (session: OAuthSession) => {
		const nextAgent = new OAuthUserAgent(session);
		const did = nextAgent.sub as Did;
		state = { kind: "profile-loading", agent: nextAgent, did };
		const known = loadAccounts().find((account) => account.did === did);
		persistActive(did, known?.handle ?? did);
		void hydrateProfile(did, nextAgent);
	};

	// prune dead sessions when re-adoption fails.
	const activate = async (did: Did): Promise<boolean> => {
		try {
			const session = await getSession(did, { allowStale: true });
			adoptSession(session);
			return true;
		} catch (cause) {
			deleteStoredSession(did);
			saveAccounts(dropAccount(loadAccounts(), did));
			state = failureState(errorMessage(cause));
			return false;
		}
	};

	const refresh = async () => {
		if (!browser) return;
		configure();
		const previousDid = currentDid();
		const previousProfile = currentProfile();
		state = { kind: "loading", did: previousDid, profile: previousProfile };
		syncAccounts();

		const candidates: Did[] = [];
		for (const candidate of [readActiveDid(), currentDid(), ...listStoredSessions()]) {
			if (candidate && !candidates.includes(candidate)) candidates.push(candidate);
		}

		let lastFailure: string | undefined;
		for (const candidate of candidates) {
			if (await activate(candidate)) return;
			const nextState = state as AuthState;
			if (nextState.kind === "failed") lastFailure = nextState.message;
		}

		resetLoggedOut(lastFailure);
	};

	const signIn = async (identifier: string, returnTo = "/") => {
		if (!browser) return;
		configure();

		const trimmed = identifier.trim();
		if (!trimmed) {
			state = failureState("Handle or DID required");
			return;
		}

		if (location.hostname === "localhost") {
			const url = new SvelteURL(location.href);
			url.hostname = "127.0.0.1";
			url.searchParams.set("identifier", trimmed);
			url.searchParams.set("return_url", returnTo);
			location.replace(url);
			return;
		}

		try {
			const url = await createAuthorizationUrl({
				target: {
					type: "account",
					identifier: trimmed as ActorIdentifier
				},
				scope: OAUTH_SCOPE,
				state: { returnTo }
			});

			window.location.assign(url.toString());
		} catch (cause) {
			state = failureState(errorMessage(cause));
			throw cause;
		}
	};

	const completeSignIn = async () => {
		if (!browser) return "/";
		configure();
		state = { kind: "authenticating" };

		try {
			const params = new SvelteURLSearchParams(location.hash.slice(1));
			history.replaceState(null, "", location.pathname + location.search);

			const { session, state } = await finalizeAuthorization(params);
			adoptSession(session);

			return returnToFromState(state);
		} catch (cause) {
			state = failureState(errorMessage(cause));
			throw cause;
		}
	};

	const switchAccount = async (did: Did) => {
		if (!browser) return;
		configure();
		if (!(await activate(did))) syncAccounts();
	};

	const removeAccount = async (did: Did) => {
		if (!browser) return;
		const wasActive = currentDid() === did;
		try {
			const activeAgent = currentAgent();
			if (wasActive && activeAgent) {
				await activeAgent.signOut();
			} else {
				deleteStoredSession(did);
			}
		} catch {
			deleteStoredSession(did);
		}

		saveAccounts(dropAccount(loadAccounts(), did));
		accounts = reconcileAccounts(listStoredSessions(), loadAccounts());

		if (wasActive) {
			const next = accounts[0]?.did ?? null;
			if (next) {
				await activate(next);
			} else {
				resetLoggedOut();
			}
		}
	};

	const signOut = async () => {
		const did = currentDid();
		if (did) {
			await removeAccount(did);
		} else {
			resetLoggedOut();
		}
	};

	const signOutAll = async () => {
		try {
			const activeAgent = currentAgent();
			if (activeAgent) await activeAgent.signOut();
		} catch {
			// remove local session state below.
		}
		if (browser) {
			for (const did of listStoredSessions()) deleteStoredSession(did);
		}
		saveAccounts([]);
		resetLoggedOut();
	};

	return {
		get state() {
			return state;
		},
		get agent() {
			return currentAgent();
		},
		get currentDid() {
			return currentDid();
		},
		get profile() {
			return currentProfile();
		},
		get bobbinUrl() {
			return bobbinUrlValue;
		},
		get error() {
			return state.kind === "failed" ? state.message : null;
		},
		get profileLoading() {
			return (
				state.kind === "profile-loading" ||
				(state.kind === "loading" && state.did !== null && state.profile === null)
			);
		},
		get authenticating() {
			return state.kind === "authenticating";
		},
		get currentUser() {
			const did = currentDid();
			if (!did) return null;
			const profile = currentProfile();
			return {
				did,
				handle: profile?.handle ?? did
			};
		},
		get accounts() {
			return accounts;
		},
		refresh,
		signIn,
		addAccount: signIn,
		completeSignIn,
		switchAccount,
		removeAccount,
		signOut,
		signOutAll
	};
};

export const getAuth = () => getContext<Auth>(AUTH_KEY);
