/**
 * GitHub forge credential lifecycle service.
 *
 * Manages the forge token lifecycle: OAuth connect (code exchange), PAT
 * connect (validation + storage), disconnect (revoke), and sanitized status
 * reads. Follows the ProviderProfileAuthService pattern — the forge token
 * is a brokered credential stored in the same SecureStore → Vault → Broker
 * chain as the OpenAI BYOK token.
 *
 * PAT validation: classic PATs (ghp_ prefix) are rejected unconditionally.
 * Fine-grained PATs are validated by probing the GitHub API to confirm the
 * token has the required scopes and repository access.
 */
import { randomBytes } from "node:crypto";
export class ForgeAuthServiceError extends Error {
    code;
    constructor(code, message) {
        super(message);
        this.code = code;
        this.name = "ForgeAuthServiceError";
    }
}
const GITHUB_API_BASE = "https://api.github.com";
const CLASSIC_PAT_PREFIX = "ghp_";
const FINE_GRAINED_PAT_PREFIX = "github_pat_";
/** Issued OAuth states expire after this window if never consumed. */
const OAUTH_STATE_TTL_MS = 10 * 60 * 1000;
/** Bound on retained pending states (cleared opportunistically). */
const OAUTH_STATE_MAX_PENDING = 32;
export const FORGE_CREDENTIAL_REF = {
    provider: "github",
    kind: "github_forge_token",
    accountId: "default",
};
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
/**
 * The OAuth redirect URI is where GitHub sends the browser after authorization.
 * The bridge only ever points a browser back at a loopback origin (the local
 * UI), never an arbitrary host — this prevents an attacker-supplied redirect
 * from harvesting the authorization code.
 */
function assertLoopbackRedirectUri(redirectUri) {
    let parsed;
    try {
        parsed = new URL(redirectUri);
    }
    catch {
        throw new ForgeAuthServiceError("oauth_redirect_uri_invalid", "The OAuth redirect URI is not a valid URL.");
    }
    if (parsed.protocol !== "http:" || !LOOPBACK_HOSTNAMES.has(parsed.hostname)) {
        throw new ForgeAuthServiceError("oauth_redirect_uri_invalid", "The OAuth redirect URI must be an http loopback origin (127.0.0.1 or localhost).");
    }
}
/** GitHub App slugs are lowercase alphanumerics and single hyphens. */
const APP_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
/**
 * Normalize a configured App slug: trim, accept a bare slug or the tail of a
 * github.com/apps/<slug> URL, and validate the charset so it can be safely
 * interpolated into the install URL. Returns undefined for an empty/invalid
 * value (the service then falls back to authorize-only).
 */
function normalizeAppSlug(raw) {
    if (typeof raw !== "string")
        return undefined;
    let slug = raw.trim();
    if (slug.length === 0)
        return undefined;
    // Accept a bare slug or the tail of either a public (github.com/apps/<slug>)
    // or settings (github.com/settings/apps/<slug>) App URL.
    const appsMatch = /github\.com\/(?:settings\/)?apps\/([^/?#]+)/i.exec(slug);
    if (appsMatch?.[1])
        slug = appsMatch[1];
    slug = slug.replace(/^\/+|\/+$/g, "").toLowerCase();
    return APP_SLUG_PATTERN.test(slug) ? slug : undefined;
}
export class GitHubForgeAuthService {
    #vault;
    #broker;
    #audit;
    #oauthClientId;
    #oauthClientSecret;
    #oauthAppSlug;
    #fetch;
    /** Single-use CSRF states issued by issueOAuthAuthorization, with expiry. */
    #pendingStates = new Map();
    constructor(options) {
        this.#vault = options.vault;
        this.#broker = options.broker;
        this.#audit = options.audit;
        this.#oauthClientId = options.oauthClientId;
        this.#oauthClientSecret = options.oauthClientSecret;
        this.#oauthAppSlug = normalizeAppSlug(options.oauthAppSlug);
        this.#fetch = options.fetchImpl ?? fetch;
    }
    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------
    /**
     * Returns the forge credential ref for a given GitHub account.
     * The accountId is resolved during connect; before connect, we use
     * a placeholder that gets updated after validation.
     */
    forgeRef() {
        return { ...FORGE_CREDENTIAL_REF };
    }
    /**
     * Read sanitized credential status. Never returns the raw token.
     */
    async status() {
        const ref = this.forgeRef();
        const credentialStatus = await this.#vault.status(ref);
        return this.#toPublicStatus(credentialStatus);
    }
    /**
     * Connect a forge token — either via OAuth code exchange or direct PAT.
     *
     * OAuth: exchanges the code for an access token, resolves the GitHub
     * user, and stores the token.
     *
     * PAT: validates the token is fine-grained (rejects classic), probes
     * the GitHub API to confirm scopes, resolves the user, and stores.
     */
    async connect(input) {
        if (input.mode === "oauth") {
            return this.#connectOAuth(input.code, input.state);
        }
        return this.#connectPat(input.token);
    }
    /**
     * Revoke (delete) the stored forge token.
     */
    async disconnect() {
        const ref = this.forgeRef();
        const current = await this.#vault.status(ref);
        if (current.state !== "available") {
            throw new ForgeAuthServiceError("no_token_configured", "No forge token is currently connected.");
        }
        await this.#vault.revoke(ref);
        this.#broker.release(ref);
        this.#appendAudit("credential.revoked", "warning", "Forge token revoked.");
        return this.status();
    }
    /**
     * Issue a GitHub OAuth authorization for the loopback browser popup to open.
     * Generates a single-use CSRF `state`, remembers it (with expiry), and returns
     * the authorization URL plus the state. The callback echoes the state back and
     * connect() verifies + consumes it. `redirectUri` must be a loopback origin —
     * the bridge never redirects a browser anywhere else.
     *
     * Throws ForgeAuthServiceError("oauth_not_configured") when the bridge has no
     * GitHub App client id, and ("oauth_redirect_uri_invalid") for a non-loopback
     * or malformed redirect URI.
     */
    issueOAuthAuthorization(input) {
        if (!this.#oauthClientId) {
            throw new ForgeAuthServiceError("oauth_not_configured", "GitHub App OAuth is not configured on the bridge. Set PATCHTRAIL_GITHUB_APP_CLIENT_ID and PATCHTRAIL_GITHUB_APP_CLIENT_SECRET.");
        }
        assertLoopbackRedirectUri(input.redirectUri);
        const state = randomBytes(32).toString("base64url");
        this.#rememberState(state);
        // With a configured App slug, use the install-and-authorize flow so the user
        // selects repositories AND authorizes in one GitHub screen; the callback
        // returns code + installation_id + setup_action + the preserved state. GitHub
        // ignores redirect_uri here and uses the App's registered callback URL, so we
        // only carry the state. Without a slug, fall back to authorize-only.
        if (this.#oauthAppSlug) {
            const params = new URLSearchParams({ state });
            return {
                authorizeUrl: `https://github.com/apps/${this.#oauthAppSlug}/installations/new?${params.toString()}`,
                state,
            };
        }
        const params = new URLSearchParams({
            client_id: this.#oauthClientId,
            redirect_uri: input.redirectUri,
            state,
            scope: "repo", // GitHub App will narrow this based on App registration
            allow_signup: "false",
        });
        return {
            authorizeUrl: `https://github.com/login/oauth/authorize?${params.toString()}`,
            state,
        };
    }
    #rememberState(state) {
        const now = Date.now();
        // Opportunistic prune of expired entries before inserting a new one.
        for (const [existing, expiresAt] of this.#pendingStates) {
            if (expiresAt <= now)
                this.#pendingStates.delete(existing);
        }
        // Bound retained pending states (drop oldest insertion if over the cap).
        while (this.#pendingStates.size >= OAUTH_STATE_MAX_PENDING) {
            const oldest = this.#pendingStates.keys().next().value;
            if (oldest === undefined)
                break;
            this.#pendingStates.delete(oldest);
        }
        this.#pendingStates.set(state, now + OAUTH_STATE_TTL_MS);
    }
    /** Verify + consume a previously issued state (single use). */
    #consumeState(state) {
        const expiresAt = this.#pendingStates.get(state);
        if (expiresAt === undefined)
            return false;
        this.#pendingStates.delete(state);
        return expiresAt > Date.now();
    }
    // ---------------------------------------------------------------------------
    // OAuth connect
    // ---------------------------------------------------------------------------
    async #connectOAuth(code, state) {
        if (!this.#oauthClientId || !this.#oauthClientSecret) {
            throw new ForgeAuthServiceError("oauth_code_exchange_failed", "GitHub App OAuth credentials are not configured on the bridge.");
        }
        // CSRF: when a state was issued (the wired browser flow always issues one),
        // it must verify single-use before we exchange the code. A missing state is
        // only tolerated for the legacy direct-code path (no issued state to match).
        if (state !== undefined && !this.#consumeState(state)) {
            throw new ForgeAuthServiceError("oauth_state_invalid", "The OAuth state is unknown, expired, or already used. Restart the GitHub connection.");
        }
        // Exchange code for access token.
        let token;
        try {
            const response = await this.#fetch("https://github.com/login/oauth/access_token", {
                method: "POST",
                headers: {
                    "content-type": "application/json",
                    accept: "application/json",
                    "user-agent": "PatchTrail (local-first forge; D085)",
                },
                body: JSON.stringify({
                    client_id: this.#oauthClientId,
                    client_secret: this.#oauthClientSecret,
                    code,
                }),
            });
            if (!response.ok) {
                throw new ForgeAuthServiceError("oauth_code_exchange_failed", `GitHub returned ${response.status} during code exchange.`);
            }
            const data = (await response.json());
            if (data.error || !data.access_token) {
                throw new ForgeAuthServiceError("oauth_code_exchange_failed", data.error ?? "No access token in OAuth response.");
            }
            token = data.access_token;
        }
        catch (error) {
            if (error instanceof ForgeAuthServiceError)
                throw error;
            throw new ForgeAuthServiceError("oauth_code_exchange_failed", "Failed to exchange OAuth code for token.");
        }
        // Validate and resolve user.
        return this.#validateAndStore(token);
    }
    // ---------------------------------------------------------------------------
    // PAT connect
    // ---------------------------------------------------------------------------
    async #connectPat(rawToken) {
        const token = rawToken.trim();
        // Reject classic PATs unconditionally.
        if (token.startsWith(CLASSIC_PAT_PREFIX)) {
            throw new ForgeAuthServiceError("classic_pat_rejected", "Classic personal access tokens are not supported. Use a fine-grained PAT scoped to specific repositories with contents:write and pull-requests:write permissions.");
        }
        // Fine-grained PATs must start with github_pat_.
        if (!token.startsWith(FINE_GRAINED_PAT_PREFIX)) {
            throw new ForgeAuthServiceError("pat_scope_insufficient", "Unrecognized token format. Only fine-grained GitHub PATs (github_pat_...) and OAuth tokens are supported.");
        }
        if (token.length > 4096) {
            throw new ForgeAuthServiceError("pat_scope_insufficient", "Token exceeds the maximum length.");
        }
        return this.#validateAndStore(token);
    }
    // ---------------------------------------------------------------------------
    // Validation + storage
    // ---------------------------------------------------------------------------
    async #validateAndStore(token) {
        // Resolve the GitHub user to get the accountId.
        let username;
        try {
            const userResponse = await this.#fetch(`${GITHUB_API_BASE}/user`, {
                headers: {
                    authorization: `Bearer ${token}`,
                    accept: "application/vnd.github+json",
                    "user-agent": "PatchTrail (local-first forge; D085)",
                },
            });
            if (!userResponse.ok) {
                throw new ForgeAuthServiceError("token_validation_failed", `GitHub rejected the token (${userResponse.status}). The token may be expired, revoked, or invalid.`);
            }
            const user = (await userResponse.json());
            if (!user.login) {
                throw new ForgeAuthServiceError("token_validation_failed", "Could not resolve GitHub user from token.");
            }
            username = user.login;
        }
        catch (error) {
            if (error instanceof ForgeAuthServiceError)
                throw error;
            throw new ForgeAuthServiceError("token_validation_failed", "Failed to validate token with GitHub.");
        }
        // Probe repository access — list repos the token can access.
        let repositories = [];
        try {
            const reposResponse = await this.#fetch(`${GITHUB_API_BASE}/user/repos?per_page=100&affiliation=owner,collaborator&sort=updated`, {
                headers: {
                    authorization: `Bearer ${token}`,
                    accept: "application/vnd.github+json",
                    "user-agent": "PatchTrail (local-first forge; D085)",
                },
            });
            if (reposResponse.ok) {
                const repos = (await reposResponse.json());
                repositories = repos
                    .map((r) => r.full_name)
                    .filter((n) => typeof n === "string" && n.length > 0);
            }
        }
        catch {
            // Non-fatal: the token may have limited repo access but still work
            // for specific repos the user enters.
        }
        // Store the token with the default account id (the ref is always the same).
        // The GitHub username is surfaced in the status response as metadata, not
        // as part of the storage key.
        const status = await this.#vault.put(FORGE_CREDENTIAL_REF, token);
        this.#appendAudit("credential.updated", "warning", `Forge token stored for GitHub user ${username}.`);
        return {
            state: status.state,
            ...(status.maskedLabel ? { maskedLabel: status.maskedLabel } : {}),
            ...(status.updatedAt ? { updatedAt: status.updatedAt } : {}),
            ...(status.backend ? { backend: status.backend } : {}),
            ...(status.degraded !== undefined ? { degraded: status.degraded } : {}),
            accountId: username,
            repositories,
        };
    }
    // ---------------------------------------------------------------------------
    // Helpers
    // ---------------------------------------------------------------------------
    async #toPublicStatus(status) {
        return {
            state: status.state,
            ...(status.maskedLabel ? { maskedLabel: status.maskedLabel } : {}),
            ...(status.updatedAt ? { updatedAt: status.updatedAt } : {}),
            ...(status.backend ? { backend: status.backend } : {}),
            ...(status.degraded !== undefined ? { degraded: status.degraded } : {}),
            accountId: status.ref.accountId,
            repositories: [],
        };
    }
    #appendAudit(type, severity, message) {
        this.#audit?.append({
            type,
            severity,
            message,
            metadata: {
                provider: "github",
                kind: "github_forge_token",
                reasonCode: type === "credential.updated"
                    ? "forge-connected"
                    : "forge-disconnected",
            },
        });
    }
}
