/**
 * GitHub Copilot OAuth flow
 *
 * UPSTREAM AUDIT (2026-05-02): STAY HAND-ROLLED
 *
 * Candidate: @octokit/auth-oauth-device (v8.0.3)
 * Coverage: device-code initiation + authorization_pending/slow_down polling — the
 *           ~120 LOC in startDeviceFlow + pollForGitHubAccessToken only.
 * Why we're not delegating:
 *   1. AbortSignal cancellation — the library has no signal/abort support; our
 *      abortableSleep + signal checks are load-bearing for the login-cancel UX.
 *   2. 74% of this file is Copilot-proprietary with no upstream equivalent:
 *      - copilot_internal/v2/token exchange (refreshGitHubCopilotToken)
 *      - proxy-ep token parsing / base-URL derivation (getBaseUrlFromToken)
 *      - Model policy enablement (enableAllGitHubCopilotModels)
 *      - Model limits fetch (fetchCopilotModelLimits)
 *      - Enterprise domain normalization (normalizeDomain / getUrls)
 *   3. The library would add a dependency + lose abort support for a ~26% LOC
 *      reduction in a 460-line file — not worth it.
 *
 * Re-audit trigger: if @octokit/auth-oauth-device adds AbortSignal support AND
 * the Copilot-specific surface area shrinks (e.g., models API becomes public SDK).
 *
 * UPSTREAM AUDIT (2026-05-02): opencode-copilot-auth + three other candidates — STAY HAND-ROLLED
 *
 * Four packages inspected (full source read for each):
 *
 * 1. opencode-copilot-auth@0.0.12 (thdxr / ironbay.co; 13.6 kB unpacked; 0 runtime deps;
 *    latest version 2026-01-11; single maintainer; pre-1.0 with 11 versions since Aug 2025)
 *    Source: /tmp/package/index.mjs (extracted from tarball)
 *    Exports: one named export — CopilotAuthPlugin({ client }) — an opencode plugin factory.
 *    ALL logic is inlined inside that single async function and is NOT separately callable:
 *      - authorize() [lines 210-299]: device-code initiation + polling loop. Covers our
 *        startDeviceFlow + pollForGitHubAccessToken. BUT: (a) infinite while(true) loop with
 *        no expiry deadline (our pollForGitHubAccessToken has Date.now() < deadline guard);
 *        (b) no AbortSignal support — cannot be cancelled; (c) missing slow_down interval
 *        back-off (our file handles slow_down by adding 5 s to intervalMs); (d) returns
 *        { type:"success", refresh, access:"", expires:0 } — defers the copilot-token
 *        exchange to the loader, not the authorize step.
 *      - loader() [lines 45-165]: inline copilot_internal/v2/token refresh + expiry check.
 *        Covers our refreshGitHubCopilotToken. BUT: immediately calls client.auth.set() to
 *        persist — the storage call cannot be skipped; there is no way to get the token
 *        without also writing it through opencode's auth API.
 *      - Does NOT cover: proxy-ep URL parsing (hardcodes api.githubcopilot.com), model
 *        policy enablement, fetchCopilotModelLimits, or AbortSignal cancellation.
 *    Net coverage of our 480 LOC: ~30% (device-code dance + token exchange) — below the
 *    60% threshold AND the usable subset requires surgery to detach from client.auth.set().
 *    Risk factors: pre-1.0, single maintainer, Proprietary license (not MIT/Apache).
 *
 * 2. copilot-api@0.7.0 (ericc-ch / echristian; 171.5 kB unpacked; 11 deps)
 *    Source: dist/main.js — starts with #!/usr/bin/env node shebang.
 *    It is a CLI proxy server (hono + srvx), not a library. Exports: `export { }` (empty).
 *    All auth code (device flow, copilot_internal/v2/token, refresh loop) is internal to
 *    the CLI's start command and not callable as a module. Zero reuse possible.
 *
 * 3. @github/copilot-language-server@1.480.0 (official GitHub; 134 MB unpacked)
 *    dist/api/types.d.ts exports only ContextProviderApiV1 (a VS Code extension host API
 *    for injecting prompt context). The package ships a single language-server binary;
 *    no OAuth functions are exported or accessible programmatically.
 *
 * 4. @octokit/auth-oauth-device@8.0.3 — already audited above (26% coverage, no AbortSignal).
 *
 * Conclusion: No candidate reaches the 60% coverage bar. The closest (opencode-copilot-auth)
 * covers ~30% and cannot be used without its storage side-effect. STAY HAND-ROLLED.
 *
 * CREDS-FILE AUDIT (2026-05-02): STAY HAND-ROLLED — device-code dance still needed
 *
 * Investigated five candidate file sources for a "consume existing token" fast-path:
 *   1. ~/.copilot/session-state/*.jsonl — conversation history (type/data/id/timestamp),
 *      no OAuth tokens of any kind.
 *   2. ~/.config/github-copilot/hosts.json and apps.json (Neovim plugin pattern) —
 *      neither file nor directory exists on this machine.
 *   3. ~/.config/gh/hosts.yml — contains a gho_* token (40 chars) scoped to
 *      repo/gist/read:org etc.; copilot_internal scope absent. Exchange against
 *      GET api.github.com/copilot_internal/v2/token returns HTTP 404 "Not Found".
 *   4. ~/.maschine/copilot-token.json — OUR OWN app's cache (singularity/machine
 *      CopilotSubscriptionProvider). Has { githubToken, copilotToken, expiresAt,
 *      refreshIn }. The stored githubToken IS a Copilot-scoped gho_* (40 chars)
 *      and DOES exchange successfully (HTTP 200, fresh 353-char proxy-ep token).
 *      However: (a) that file is written by singularity/machine after device-flow
 *      login there, not by a third-party tool we can rely on; (b) it was last
 *      written 2025-12-30, so on a fresh machine it won't exist; (c) consuming it
 *      here would create cross-app token sharing with no clear ownership boundary.
 *   5. opencode-copilot-auth@0.0.9 in ~/.bun/install/cache — a bun plugin that
 *      also does the device-code dance and stores state via opencode's auth.set()
 *      API; not a plain filesystem file we can read.
 *
 * Conclusion: No third-party-written creds file exists on this machine that carries
 * a Copilot-scoped token. The gh CLI token lacks the required Copilot scope.
 * The device-code dance (startDeviceFlow + pollForGitHubAccessToken) is the only
 * way to obtain a fresh Copilot-authorized github token for a new login.
 *
 * Future: if the user installs the Neovim Copilot plugin or VS Code's Copilot
 * extension writes ~/.config/github-copilot/apps.json (format:
 *   { "github.com:Iv1.b507a08c87ecfe98": { "oauth_token": "gho_..." } }),
 * we could consume that as a fast-path that skips the device-code dance entirely
 * and goes straight to refreshGitHubCopilotToken(). Worth adding then.
 */

import { getModels } from "../../models.js";
import type { Api, Model } from "../../types.js";
import type {
	OAuthCredentials,
	OAuthLoginCallbacks,
	OAuthProviderInterface,
} from "./types.js";

type CopilotCredentials = OAuthCredentials & {
	enterpriseUrl?: string;
	/** Model limits from the /models API, keyed by model ID */
	modelLimits?: Record<string, { contextWindow: number; maxTokens: number }>;
};

const decode = (s: string) => atob(s);
const CLIENT_ID = decode("SXYxLmI1MDdhMDhjODdlY2ZlOTg=");

const COPILOT_HEADERS = {
	"User-Agent": "GitHubCopilotChat/0.35.0",
	"Editor-Version": "vscode/1.107.0",
	"Editor-Plugin-Version": "copilot-chat/0.35.0",
	"Copilot-Integration-Id": "vscode-chat",
} as const;

type DeviceCodeResponse = {
	device_code: string;
	user_code: string;
	verification_uri: string;
	interval: number;
	expires_in: number;
};

type DeviceTokenSuccessResponse = {
	access_token: string;
	token_type?: string;
	scope?: string;
};

type DeviceTokenErrorResponse = {
	error: string;
	error_description?: string;
	interval?: number;
};

export function normalizeDomain(input: string): string | null {
	const trimmed = input.trim();
	if (!trimmed) return null;
	try {
		const url = trimmed.includes("://")
			? new URL(trimmed)
			: new URL(`https://${trimmed}`);
		return url.hostname;
	} catch {
		return null;
	}
}

function getUrls(domain: string): {
	deviceCodeUrl: string;
	accessTokenUrl: string;
	copilotTokenUrl: string;
} {
	return {
		deviceCodeUrl: `https://${domain}/login/device/code`,
		accessTokenUrl: `https://${domain}/login/oauth/access_token`,
		copilotTokenUrl: `https://api.${domain}/copilot_internal/v2/token`,
	};
}

/**
 * Parse the proxy-ep from a Copilot token and convert to API base URL.
 * Token format: tid=...;exp=...;proxy-ep=proxy.individual.githubcopilot.com;...
 * Returns API URL like https://api.individual.githubcopilot.com
 */
function getBaseUrlFromToken(token: string): string | null {
	const match = token.match(/proxy-ep=([^;]+)/);
	if (!match) return null;
	const proxyHost = match[1];
	// Convert proxy.xxx to api.xxx
	const apiHost = proxyHost.replace(/^proxy\./, "api.");
	return `https://${apiHost}`;
}

export function getGitHubCopilotBaseUrl(
	token?: string,
	enterpriseDomain?: string,
): string {
	// If we have a token, extract the base URL from proxy-ep
	if (token) {
		const urlFromToken = getBaseUrlFromToken(token);
		if (urlFromToken) return urlFromToken;
	}
	// Fallback for enterprise or if token parsing fails
	if (enterpriseDomain) return `https://copilot-api.${enterpriseDomain}`;
	return "https://api.individual.githubcopilot.com";
}

async function fetchJson(url: string, init: RequestInit): Promise<unknown> {
	const response = await fetch(url, {
		...init,
		signal: init.signal ?? AbortSignal.timeout(30_000),
	});
	if (!response.ok) {
		const text = await response.text();
		throw new Error(`${response.status} ${response.statusText}: ${text}`);
	}
	return response.json();
}

async function startDeviceFlow(domain: string): Promise<DeviceCodeResponse> {
	const urls = getUrls(domain);
	const data = await fetchJson(urls.deviceCodeUrl, {
		method: "POST",
		headers: {
			Accept: "application/json",
			"Content-Type": "application/json",
			"User-Agent": "GitHubCopilotChat/0.35.0",
		},
		body: JSON.stringify({
			client_id: CLIENT_ID,
			scope: "read:user",
		}),
	});

	if (!data || typeof data !== "object") {
		throw new Error("Invalid device code response");
	}

	const deviceCode = (data as Record<string, unknown>).device_code;
	const userCode = (data as Record<string, unknown>).user_code;
	const verificationUri = (data as Record<string, unknown>).verification_uri;
	const interval = (data as Record<string, unknown>).interval;
	const expiresIn = (data as Record<string, unknown>).expires_in;

	if (
		typeof deviceCode !== "string" ||
		typeof userCode !== "string" ||
		typeof verificationUri !== "string" ||
		typeof interval !== "number" ||
		typeof expiresIn !== "number"
	) {
		throw new Error("Invalid device code response fields");
	}

	return {
		device_code: deviceCode,
		user_code: userCode,
		verification_uri: verificationUri,
		interval,
		expires_in: expiresIn,
	};
}

/**
 * Sleep that can be interrupted by an AbortSignal
 */
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
	return new Promise((resolve, reject) => {
		if (signal?.aborted) {
			reject(new Error("Login cancelled"));
			return;
		}

		const timeout = setTimeout(resolve, ms);

		signal?.addEventListener(
			"abort",
			() => {
				clearTimeout(timeout);
				reject(new Error("Login cancelled"));
			},
			{ once: true },
		);
	});
}

async function pollForGitHubAccessToken(
	domain: string,
	deviceCode: string,
	intervalSeconds: number,
	expiresIn: number,
	signal?: AbortSignal,
) {
	const urls = getUrls(domain);
	const deadline = Date.now() + expiresIn * 1000;
	let intervalMs = Math.max(1000, Math.floor(intervalSeconds * 1000));

	while (Date.now() < deadline) {
		if (signal?.aborted) {
			throw new Error("Login cancelled");
		}

		const raw = await fetchJson(urls.accessTokenUrl, {
			method: "POST",
			headers: {
				Accept: "application/json",
				"Content-Type": "application/json",
				"User-Agent": "GitHubCopilotChat/0.35.0",
			},
			body: JSON.stringify({
				client_id: CLIENT_ID,
				device_code: deviceCode,
				grant_type: "urn:ietf:params:oauth:grant-type:device_code",
			}),
		});

		if (
			raw &&
			typeof raw === "object" &&
			typeof (raw as DeviceTokenSuccessResponse).access_token === "string"
		) {
			return (raw as DeviceTokenSuccessResponse).access_token;
		}

		if (
			raw &&
			typeof raw === "object" &&
			typeof (raw as DeviceTokenErrorResponse).error === "string"
		) {
			const err = (raw as DeviceTokenErrorResponse).error;
			if (err === "authorization_pending") {
				await abortableSleep(intervalMs, signal);
				continue;
			}

			if (err === "slow_down") {
				intervalMs += 5000;
				await abortableSleep(intervalMs, signal);
				continue;
			}

			throw new Error(`Device flow failed: ${err}`);
		}

		await abortableSleep(intervalMs, signal);
	}

	throw new Error("Device flow timed out");
}

/**
 * Refresh GitHub Copilot token
 */
export async function refreshGitHubCopilotToken(
	refreshToken: string,
	enterpriseDomain?: string,
): Promise<OAuthCredentials> {
	const domain = enterpriseDomain || "github.com";
	const urls = getUrls(domain);

	const raw = await fetchJson(urls.copilotTokenUrl, {
		headers: {
			Accept: "application/json",
			Authorization: `Bearer ${refreshToken}`,
			...COPILOT_HEADERS,
		},
	});

	if (!raw || typeof raw !== "object") {
		throw new Error("Invalid Copilot token response");
	}

	const token = (raw as Record<string, unknown>).token;
	const expiresAt = (raw as Record<string, unknown>).expires_at;

	if (typeof token !== "string" || typeof expiresAt !== "number") {
		throw new Error("Invalid Copilot token response fields");
	}

	return {
		refresh: refreshToken,
		access: token,
		expires: expiresAt * 1000 - 5 * 60 * 1000,
		enterpriseUrl: enterpriseDomain,
	};
}

/**
 * Enable a model for the user's GitHub Copilot account.
 * This is required for some models (like Claude, Grok) before they can be used.
 */
async function enableGitHubCopilotModel(
	token: string,
	modelId: string,
	enterpriseDomain?: string,
): Promise<boolean> {
	const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
	const url = `${baseUrl}/models/${modelId}/policy`;

	try {
		const response = await fetch(url, {
			method: "POST",
			headers: {
				"Content-Type": "application/json",
				Authorization: `Bearer ${token}`,
				...COPILOT_HEADERS,
				"openai-intent": "chat-policy",
				"x-interaction-type": "chat-policy",
			},
			body: JSON.stringify({ state: "enabled" }),
			signal: AbortSignal.timeout(30_000),
		});
		return response.ok;
	} catch {
		return false;
	}
}

/**
 * Enable all known GitHub Copilot models that may require policy acceptance.
 * Called after successful login to ensure all models are available.
 */
async function enableAllGitHubCopilotModels(
	token: string,
	enterpriseDomain?: string,
	onProgress?: (model: string, success: boolean) => void,
): Promise<void> {
	const models = getModels("github-copilot");
	await Promise.all(
		models.map(async (model) => {
			const success = await enableGitHubCopilotModel(
				token,
				model.id,
				enterpriseDomain,
			);
			onProgress?.(model.id, success);
		}),
	);
}

async function fetchCopilotModelLimits(
	token: string,
	enterpriseDomain?: string,
): Promise<Record<string, { contextWindow: number; maxTokens: number }>> {
	const baseUrl = getGitHubCopilotBaseUrl(token, enterpriseDomain);
	try {
		const response = await fetch(`${baseUrl}/models`, {
			headers: {
				Accept: "application/json",
				Authorization: `Bearer ${token}`,
				"X-GitHub-Api-Version": "2025-05-01",
				...COPILOT_HEADERS,
			},
			signal: AbortSignal.timeout(30_000),
		});
		if (!response.ok) return {};
		const data = (await response.json()) as {
			data?: Array<{
				id: string;
				capabilities?: {
					limits?: {
						max_context_window_tokens?: number;
						max_output_tokens?: number;
					};
				};
			}>;
		};
		const limits: Record<string, { contextWindow: number; maxTokens: number }> =
			{};
		for (const m of data.data || []) {
			const ctx = m.capabilities?.limits?.max_context_window_tokens;
			const out = m.capabilities?.limits?.max_output_tokens;
			if (
				typeof ctx === "number" &&
				typeof out === "number" &&
				ctx > 0 &&
				out > 0 &&
				Number.isFinite(ctx) &&
				Number.isFinite(out)
			) {
				limits[m.id] = { contextWindow: ctx, maxTokens: out };
			}
		}
		return limits;
	} catch {
		return {};
	}
}

/**
 * Login with GitHub Copilot OAuth (device code flow)
 *
 * @param options.onAuth - Callback with URL and optional instructions (user code)
 * @param options.onPrompt - Callback to prompt user for input
 * @param options.onProgress - Optional progress callback
 * @param options.signal - Optional AbortSignal for cancellation
 */
export async function loginGitHubCopilot(options: {
	onAuth: (url: string, instructions?: string) => void;
	onPrompt: (prompt: {
		message: string;
		placeholder?: string;
		allowEmpty?: boolean;
	}) => Promise<string>;
	onProgress?: (message: string) => void;
	signal?: AbortSignal;
}): Promise<OAuthCredentials> {
	const input = await options.onPrompt({
		message: "GitHub Enterprise URL/domain (blank for github.com)",
		placeholder: "company.ghe.com",
		allowEmpty: true,
	});

	if (options.signal?.aborted) {
		throw new Error("Login cancelled");
	}

	const trimmed = input.trim();
	const enterpriseDomain = normalizeDomain(input);
	if (trimmed && !enterpriseDomain) {
		throw new Error("Invalid GitHub Enterprise URL/domain");
	}
	const domain = enterpriseDomain || "github.com";

	const device = await startDeviceFlow(domain);
	options.onAuth(device.verification_uri, `Enter code: ${device.user_code}`);

	const githubAccessToken = await pollForGitHubAccessToken(
		domain,
		device.device_code,
		device.interval,
		device.expires_in,
		options.signal,
	);
	const credentials = await refreshGitHubCopilotToken(
		githubAccessToken,
		enterpriseDomain ?? undefined,
	);

	// Enable all models after successful login
	options.onProgress?.("Enabling models...");
	await enableAllGitHubCopilotModels(
		credentials.access,
		enterpriseDomain ?? undefined,
	);

	// Fetch real model limits from the Copilot API
	options.onProgress?.("Fetching model limits...");
	const modelLimits = await fetchCopilotModelLimits(
		credentials.access,
		enterpriseDomain ?? undefined,
	);
	if (Object.keys(modelLimits).length > 0) {
		(credentials as CopilotCredentials).modelLimits = modelLimits;
	}

	return credentials;
}

export const githubCopilotOAuthProvider: OAuthProviderInterface = {
	id: "github-copilot",
	name: "GitHub Copilot",

	async login(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
		return loginGitHubCopilot({
			onAuth: (url, instructions) => callbacks.onAuth({ url, instructions }),
			onPrompt: callbacks.onPrompt,
			onProgress: callbacks.onProgress,
			signal: callbacks.signal,
		});
	},

	async refreshToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
		const creds = credentials as CopilotCredentials;
		const refreshed = await refreshGitHubCopilotToken(
			creds.refresh,
			creds.enterpriseUrl,
		);
		try {
			const modelLimits = await fetchCopilotModelLimits(
				refreshed.access,
				creds.enterpriseUrl,
			);
			if (Object.keys(modelLimits).length > 0) {
				(refreshed as CopilotCredentials).modelLimits = modelLimits;
			}
		} catch {
			// Model limits fetch is best-effort; don't block token refresh
		}
		return refreshed;
	},

	getApiKey(credentials: OAuthCredentials): string {
		return credentials.access;
	},

	modifyModels(
		models: Model<Api>[],
		credentials: OAuthCredentials,
	): Model<Api>[] {
		const creds = credentials as CopilotCredentials;
		const domain = creds.enterpriseUrl
			? (normalizeDomain(creds.enterpriseUrl) ?? undefined)
			: undefined;
		const baseUrl = getGitHubCopilotBaseUrl(creds.access, domain);
		const limits = creds.modelLimits;
		const availableModelIds = limits ? new Set(Object.keys(limits)) : null;
		const shouldFilterByAvailability =
			!!availableModelIds && availableModelIds.size > 0;
		return models.flatMap((m) => {
			if (m.provider !== "github-copilot") return m;
			if (shouldFilterByAvailability && !availableModelIds.has(m.id)) return [];
			const modelLimits = limits?.[m.id];
			return {
				...m,
				baseUrl,
				...(modelLimits && {
					contextWindow: modelLimits.contextWindow,
					maxTokens: modelLimits.maxTokens,
				}),
			};
		});
	},
};
