import http from "http";
import crypto from "crypto";
import fs from "fs/promises";
import os from "os";
import path from "path";
import { execFile } from "child_process";
import { getEncryptionKey, encrypt, decrypt } from "./oauthEncryption.js";
import { runHttp } from "./http.js";
import { AgencyCancelledError } from "../runtime/errors.js";
import { getRuntimeContext } from "../runtime/asyncContext.js";
function getTokenDir() {
    return process.env.AGENCY_OAUTH_TOKEN_DIR || path.join(os.homedir(), ".agency", "oauth");
}
const DEFAULT_PORT = 8914;
const EXPIRY_BUFFER_MS = 60000;
const AUTH_TIMEOUT_MS = 300000;
const VALID_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
// Per-provider mutex to prevent concurrent refresh races
const refreshLocks = {};
function requireHttps(url, label) {
    const parsed = new URL(url);
    if (parsed.protocol !== "https:" && parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1") {
        throw new Error(`${label} must use HTTPS (got "${parsed.protocol}//"). ` +
            `HTTP is only allowed for localhost during development.`);
    }
}
function getTokenPath(name) {
    if (!VALID_NAME_PATTERN.test(name)) {
        throw new Error(`Invalid OAuth provider name: "${name}". Use only letters, numbers, dots, hyphens, and underscores.`);
    }
    return path.join(getTokenDir(), `${name}.json`);
}
function generateCodeVerifier() {
    return crypto.randomBytes(32).toString("base64url");
}
function generateCodeChallenge(verifier) {
    return crypto.createHash("sha256").update(verifier).digest("base64url");
}
function escapeHtml(str) {
    return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
}
function parseExtraParams(str) {
    if (!str.trim())
        return {};
    const result = {};
    for (const pair of str.trim().split(/\s+/)) {
        const eqIdx = pair.indexOf("=");
        if (eqIdx > 0) {
            result[pair.slice(0, eqIdx)] = pair.slice(eqIdx + 1);
        }
    }
    return result;
}
function openBrowser(url) {
    if (process.platform === "darwin") {
        execFile("open", [url], () => { });
    }
    else if (process.platform === "win32") {
        execFile("cmd.exe", ["/c", "start", "", url], () => { });
    }
    else {
        execFile("xdg-open", [url], () => { });
    }
}
function waitForCallback(port, signal) {
    return new Promise((resolve, reject) => {
        let settled = false;
        const server = http.createServer((req, res) => {
            if (settled) {
                res.writeHead(200);
                res.end();
                return;
            }
            const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
            // Only handle the callback path; ignore favicon, prefetch, etc.
            if (url.pathname !== "/oauth/callback" && url.pathname !== "/") {
                res.writeHead(404);
                res.end();
                return;
            }
            const code = url.searchParams.get("code");
            const error = url.searchParams.get("error");
            const state = url.searchParams.get("state") ?? "";
            if (error) {
                settled = true;
                res.writeHead(400, { "Content-Type": "text/html" });
                res.end(`<h1>Authorization failed</h1><p>${escapeHtml(error)}</p>`);
                cleanup();
                reject(new Error(`OAuth authorization failed: ${error}`));
                return;
            }
            if (!code) {
                res.writeHead(400, { "Content-Type": "text/html" });
                res.end("<h1>Missing authorization code</h1>");
                return;
            }
            settled = true;
            res.writeHead(200, { "Content-Type": "text/html" });
            res.end("<h1>Authorization successful!</h1><p>You can close this tab and return to your terminal.</p>");
            cleanup();
            resolve({ code, state });
        });
        server.on("error", (err) => {
            settled = true;
            cleanup();
            if (err.code === "EADDRINUSE") {
                reject(new Error(`OAuth callback port ${port} is already in use. Try a different port.`));
            }
            else {
                reject(new Error(`OAuth callback server error: ${err.message}`));
            }
        });
        server.listen(port, "127.0.0.1");
        const timer = setTimeout(() => {
            if (!settled) {
                settled = true;
                cleanup();
                reject(new Error("OAuth authorization timed out (5 minutes)"));
            }
        }, AUTH_TIMEOUT_MS);
        timer.unref();
        // On abort we tear down the listening server (otherwise it would
        // sit on the port for the full 5-minute timeout) and reject with
        // cancellation so `__tryCall` re-throws.
        const onAbort = () => {
            if (settled)
                return;
            settled = true;
            cleanup();
            reject(new AgencyCancelledError("OAuth authorize cancelled"));
        };
        if (signal) {
            if (signal.aborted)
                onAbort();
            else
                signal.addEventListener("abort", onAbort, { once: true });
        }
        function cleanup() {
            clearTimeout(timer);
            server.close();
            if (signal)
                signal.removeEventListener("abort", onAbort);
        }
    });
}
async function exchangeCodeForTokens(tokenUrl, params, signal) {
    const body = new URLSearchParams(params);
    return runHttp(async () => {
        const response = await fetch(tokenUrl, {
            method: "POST",
            headers: { "Content-Type": "application/x-www-form-urlencoded" },
            body: body.toString(),
            signal,
        });
        if (!response.ok) {
            const responseBody = await response.text();
            const truncated = responseBody.length > 200
                ? responseBody.slice(0, 200) + "..."
                : responseBody;
            throw new Error(`OAuth token exchange failed (${response.status}): ${truncated}`);
        }
        const data = (await response.json());
        if (!data.access_token) {
            throw new Error("OAuth token response missing access_token");
        }
        return {
            access_token: data.access_token,
            refresh_token: data.refresh_token ?? "",
            expires_in: Number(data.expires_in) || 3600,
        };
    }, tokenUrl);
}
async function saveTokens(name, tokens) {
    await fs.mkdir(getTokenDir(), { recursive: true });
    const json = JSON.stringify(tokens, null, 2);
    const key = await getEncryptionKey();
    const content = key ? encrypt(json, key) : json;
    await fs.writeFile(getTokenPath(name), content, {
        encoding: "utf-8",
        mode: 0o600,
    });
}
async function loadTokens(name) {
    const tokenPath = getTokenPath(name);
    try {
        const raw = await fs.readFile(tokenPath, "utf-8");
        const key = await getEncryptionKey();
        const json = key ? decrypt(raw, key) : raw;
        const parsed = JSON.parse(json);
        if (!parsed.access_token ||
            !parsed.token_url ||
            !parsed.client_id ||
            !parsed.client_secret ||
            typeof parsed.expires_at !== "number") {
            return null;
        }
        return parsed;
    }
    catch {
        return null;
    }
}
/**
 * Core authorize implementation. Takes an optional `AbortSignal` so
 * both the agency-side context-injected wrapper ({@link __internal_authorize})
 * and internal stdlib callers (calendar.ts's `_authorizeCalendar`,
 * which is itself agency-callable but hasn't been migrated to the
 * context-injected pattern yet) can share one body. When `signal` is
 * present, the local callback server, the timeout, and the token-
 * exchange `fetch` all tear down on abort.
 */
async function authorizeImpl(name, config, signal) {
    requireHttps(config.authUrl, "authUrl");
    requireHttps(config.tokenUrl, "tokenUrl");
    const port = (config.port && config.port > 0) ? config.port : DEFAULT_PORT;
    const redirectUri = `http://127.0.0.1:${port}/oauth/callback`;
    const state = crypto.randomBytes(16).toString("hex");
    const codeVerifier = generateCodeVerifier();
    const codeChallenge = generateCodeChallenge(codeVerifier);
    const scopes = Array.isArray(config.scopes)
        ? config.scopes.join(" ")
        : config.scopes;
    const authorizationUrl = new URL(config.authUrl);
    authorizationUrl.searchParams.set("client_id", config.clientId);
    authorizationUrl.searchParams.set("redirect_uri", redirectUri);
    authorizationUrl.searchParams.set("response_type", "code");
    authorizationUrl.searchParams.set("scope", scopes);
    authorizationUrl.searchParams.set("state", state);
    authorizationUrl.searchParams.set("code_challenge", codeChallenge);
    authorizationUrl.searchParams.set("code_challenge_method", "S256");
    if (config.extraAuthParams) {
        const params = typeof config.extraAuthParams === "string"
            ? parseExtraParams(config.extraAuthParams)
            : config.extraAuthParams;
        for (const [key, value] of Object.entries(params)) {
            authorizationUrl.searchParams.set(key, value);
        }
    }
    const callbackPromise = waitForCallback(port, signal);
    openBrowser(authorizationUrl.toString());
    const { code, state: returnedState } = await callbackPromise;
    if (returnedState !== state) {
        throw new Error("OAuth state mismatch — possible CSRF attack.");
    }
    const exchangeParams = {
        grant_type: "authorization_code",
        code,
        redirect_uri: redirectUri,
        client_id: config.clientId,
        code_verifier: codeVerifier,
    };
    if (config.clientSecret)
        exchangeParams.client_secret = config.clientSecret;
    const tokenResponse = await exchangeCodeForTokens(config.tokenUrl, exchangeParams, signal);
    const tokens = {
        access_token: tokenResponse.access_token,
        refresh_token: tokenResponse.refresh_token,
        expires_at: Date.now() + tokenResponse.expires_in * 1000,
        token_url: config.tokenUrl,
        client_id: config.clientId,
        client_secret: config.clientSecret,
    };
    await saveTokens(name, tokens);
    return { success: true };
}
/**
 * Deprecated context-injected wrapper kept in place during the ALS
 * migration so the registry/codegen path keeps working until the
 * follow-up cleanup PR removes it. New stdlib `.agency` files should
 * call `_authorize` instead.
 *
 * Context-injected: the 5-minute callback wait, the local server, and
 * the token-exchange `fetch` all see the abort signal so an aborted
 * run doesn't leave a server listening on the OAuth port.
 */
export async function __internal_authorize(ctx, stack, _threads, name, config) {
    return authorizeImpl(name, config, ctx.getAbortSignal(stack));
}
/**
 * ALS-reading replacement for `__internal_authorize`. Reads ctx/stack
 * from the AsyncLocalStorage frame so callers (both agency-side and
 * internal stdlib callers like `calendar.ts`'s `_authorizeCalendar`)
 * get full cancellation without needing to thread params.
 */
export async function _authorize(name, config) {
    const { ctx, stack } = getRuntimeContext();
    return authorizeImpl(name, config, ctx.getAbortSignal(stack));
}
async function getAccessTokenImpl(name, signal) {
    const tokens = await loadTokens(name);
    if (!tokens) {
        throw new Error(`No OAuth tokens found for "${name}". Run authorize() first.`);
    }
    if (Date.now() < tokens.expires_at - EXPIRY_BUFFER_MS) {
        return tokens.access_token;
    }
    if (!tokens.refresh_token) {
        throw new Error(`OAuth token for "${name}" has expired and no refresh token is available. Run authorize() again.`);
    }
    // Use a mutex to prevent concurrent refresh attempts for the same provider.
    if (refreshLocks[name]) {
        return refreshLocks[name];
    }
    const refreshPromise = (async () => {
        try {
            const refreshParams = {
                grant_type: "refresh_token",
                refresh_token: tokens.refresh_token,
                client_id: tokens.client_id,
            };
            if (tokens.client_secret)
                refreshParams.client_secret = tokens.client_secret;
            const refreshResponse = await exchangeCodeForTokens(tokens.token_url, refreshParams, signal);
            tokens.access_token = refreshResponse.access_token;
            tokens.expires_at = Date.now() + refreshResponse.expires_in * 1000;
            // Some providers rotate refresh tokens
            if (refreshResponse.refresh_token) {
                tokens.refresh_token = refreshResponse.refresh_token;
            }
            await saveTokens(name, tokens);
            return tokens.access_token;
        }
        finally {
            delete refreshLocks[name];
        }
    })();
    refreshLocks[name] = refreshPromise;
    return refreshPromise;
}
/**
 * Deprecated context-injected wrapper kept in place during the ALS
 * migration; see comment on `__internal_authorize`. New stdlib
 * `.agency` files should call `_getAccessToken` instead.
 *
 * Context-injected: a refresh-token exchange `fetch` can stall on
 * a slow OAuth provider; abort tears it down.
 */
export async function __internal_getAccessToken(ctx, stack, _threads, name) {
    return getAccessTokenImpl(name, ctx.getAbortSignal(stack));
}
/**
 * ALS-reading replacement for `__internal_getAccessToken`. Reads
 * ctx/stack from the AsyncLocalStorage frame so callers (both
 * agency-side and internal stdlib callers like `calendar.ts`) get
 * full refresh-token cancellation without needing to thread params.
 */
export async function _getAccessToken(name) {
    const { ctx, stack } = getRuntimeContext();
    return getAccessTokenImpl(name, ctx.getAbortSignal(stack));
}
export async function _isAuthorized(name) {
    return (await loadTokens(name)) !== null;
}
export async function _revokeAuth(name) {
    const tokenPath = getTokenPath(name);
    try {
        await fs.unlink(tokenPath);
        return { revoked: true };
    }
    catch (err) {
        if (err.code === "ENOENT") {
            return { revoked: false };
        }
        throw err;
    }
}
