/**
 * Hyper's subscription login: the device-code flow the official client uses.
 *
 *   POST /device/auth                 -> { device_code, user_code, verification_url, interval, expires_in }
 *   GET  /device/auth/<device_code>   -> { refresh_token, team_id, team_name, user_id } | { error }
 *   POST /token/exchange              -> { access_token, refresh_token, expiry, expires_in | expires_at }
 */

import { hostname } from "node:os";
import type { OAuthCredentials, OAuthLoginCallbacks } from "@earendil-works/pi-ai";
import { SITE_URL } from "./hyper.js";
import { HttpError, getJson, postJson } from "./http.js";

const DEVICE_AUTH_URL = `${SITE_URL}/device/auth`;
const TOKEN_EXCHANGE_URL = `${SITE_URL}/token/exchange`;
const DEFAULT_INTERVAL_SECONDS = 5;
const POLL_TIMEOUT_MS = 30_000;
const EXPIRY_BUFFER_MS = 30_000;
const SLOW_DOWN_INCREMENT_MS = 5_000;

const isRecord = (value: unknown): value is Record<string, unknown> =>
	typeof value === "object" && value !== null && !Array.isArray(value);

function optional(source: unknown, name: string): unknown {
	return isRecord(source) ? Object.getOwnPropertyDescriptor(source, name)?.value : undefined;
}

function text(source: unknown, name: string): string {
	const value = optional(source, name);
	if (typeof value !== "string" || value === "") throw new Error(`Hyper response is missing "${name}"`);
	return value;
}

function number(source: unknown, name: string): number | undefined {
	const value = optional(source, name);
	return typeof value === "number" && Number.isFinite(value) ? value : undefined;
}

const sleep = (ms: number, signal?: AbortSignal): Promise<void> =>
	new Promise((resolve, reject) => {
		if (signal?.aborted) {
			reject(new Error("Login cancelled"));
			return;
		}
		const onAbort = (): void => {
			clearTimeout(timer);
			reject(new Error("Login cancelled"));
		};
		const timer = setTimeout(() => {
			signal?.removeEventListener("abort", onAbort);
			resolve();
		}, ms);
		signal?.addEventListener("abort", onAbort, { once: true });
	});

interface DeviceAuthorization {
	deviceCode: string;
	userCode: string;
	verificationUrl: string;
	intervalMs: number;
	deadlineMs: number;
}

function parseDeviceAuthorization(payload: unknown): DeviceAuthorization {
	return {
		deviceCode: text(payload, "device_code"),
		userCode: text(payload, "user_code"),
		verificationUrl: text(payload, "verification_url"),
		intervalMs: Math.max(1000, (number(payload, "interval") ?? DEFAULT_INTERVAL_SECONDS) * 1000),
		deadlineMs: Date.now() + (number(payload, "expires_in") ?? 300) * 1000,
	};
}

/** One poll: the refresh token once the human approves, or how long to wait before asking again. */
async function pollOnce(
	authorization: DeviceAuthorization,
): Promise<{ refreshToken: string } | { delayMs: number }> {
	const payload = await getJson(
		`${DEVICE_AUTH_URL}/${encodeURIComponent(authorization.deviceCode)}`,
		undefined,
		POLL_TIMEOUT_MS,
	);
	const refreshToken = optional(payload, "refresh_token");
	if (typeof refreshToken === "string" && refreshToken !== "") return { refreshToken };
	const error = optional(payload, "error");
	if (error === "authorization_pending") return { delayMs: authorization.intervalMs };
	if (error === "slow_down") return { delayMs: authorization.intervalMs + SLOW_DOWN_INCREMENT_MS };
	const description = optional(payload, "error_description") ?? error;
	throw new Error(`Hyper device authorization failed: ${String(description ?? "unknown error")}`);
}

async function awaitApproval(authorization: DeviceAuthorization, signal?: AbortSignal): Promise<string> {
	while (Date.now() < authorization.deadlineMs) {
		const result = await pollOnce(authorization);
		if ("refreshToken" in result) return result.refreshToken;
		await sleep(result.delayMs, signal);
	}
	throw new Error("Hyper device authorization expired; run /login hyper again");
}

/**
 * Every exchange returns a **new** refresh token alongside the access token, and the old
 * one stops working, so the rotated value has to be persisted. Prime Agent stores whatever
 * `refreshToken()` returns, which is why this returns the exchanged refresh token rather
 * than the one it was called with.
 */
async function exchange(refreshToken: string): Promise<OAuthCredentials> {
	const payload = await postJson(TOKEN_EXCHANGE_URL, { refresh_token: refreshToken }, undefined, POLL_TIMEOUT_MS);
	const now = Date.now();
	const expiresIn = number(payload, "expires_in");
	const expiresAt = number(payload, "expires_at");
	const lifetimeMs =
		expiresIn !== undefined ? expiresIn * 1000 : expiresAt !== undefined ? expiresAt * 1000 - now : undefined;
	if (lifetimeMs === undefined || lifetimeMs <= 0) throw new Error("Hyper token exchange returned no usable expiry");
	return {
		refresh: typeof optional(payload, "refresh_token") === "string" ? text(payload, "refresh_token") : refreshToken,
		access: text(payload, "access_token"),
		expires: now + lifetimeMs - Math.min(EXPIRY_BUFFER_MS, Math.floor(lifetimeMs / 2)),
	};
}

export async function loginHyper(callbacks: OAuthLoginCallbacks): Promise<OAuthCredentials> {
	const authorization = parseDeviceAuthorization(
		await postJson(DEVICE_AUTH_URL, { device_name: `Prime Agent (${hostname()})` }),
	);
	callbacks.onAuth({
		url: authorization.verificationUrl,
		instructions: `Enter code: ${authorization.userCode}`,
	});
	return exchange(await awaitApproval(authorization, callbacks.signal));
}

/** A refresh token Hyper no longer accepts: 401 for a revoked session, 400 for a stale token. */
const rejectedRefresh = (error: unknown): boolean =>
	error instanceof HttpError && (error.status === 401 || (error.status === 400 && error.message.includes("refresh_token")));

export async function refreshHyperToken(credentials: OAuthCredentials): Promise<OAuthCredentials> {
	try {
		return await exchange(credentials.refresh);
	} catch (error) {
		if (rejectedRefresh(error)) {
			throw new Error("Hyper no longer accepts this session. Run /login hyper to re-authenticate.", {
				cause: error,
			});
		}
		throw error;
	}
}
