/**
 * Credential storage for API keys and OAuth tokens.
 * Handles loading, saving, and refreshing credentials from auth.json.
 *
 * Uses file locking to prevent race conditions when multiple pi instances
 * try to refresh tokens simultaneously.
 */
import { getEnvApiKey, getOAuthApiKey, getOAuthProvider, getOAuthProviders, } from "@jbutlerdev/pi-ai";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
import { resolveConfigValue } from "./resolve-config-value.js";
/**
 * Credential storage backed by a JSON file.
 */
export class AuthStorage {
    authPath;
    data = {};
    runtimeOverrides = new Map();
    fallbackResolver;
    constructor(authPath = join(getAgentDir(), "auth.json")) {
        this.authPath = authPath;
        this.reload();
    }
    /**
     * Set a runtime API key override (not persisted to disk).
     * Used for CLI --api-key flag.
     */
    setRuntimeApiKey(provider, apiKey) {
        this.runtimeOverrides.set(provider, apiKey);
    }
    /**
     * Remove a runtime API key override.
     */
    removeRuntimeApiKey(provider) {
        this.runtimeOverrides.delete(provider);
    }
    /**
     * Set a fallback resolver for API keys not found in auth.json or env vars.
     * Used for custom provider keys from models.json.
     */
    setFallbackResolver(resolver) {
        this.fallbackResolver = resolver;
    }
    /**
     * Reload credentials from disk.
     */
    reload() {
        if (!existsSync(this.authPath)) {
            this.data = {};
            return;
        }
        try {
            this.data = JSON.parse(readFileSync(this.authPath, "utf-8"));
        }
        catch {
            this.data = {};
        }
    }
    /**
     * Save credentials to disk.
     */
    save() {
        const dir = dirname(this.authPath);
        if (!existsSync(dir)) {
            mkdirSync(dir, { recursive: true, mode: 0o700 });
        }
        writeFileSync(this.authPath, JSON.stringify(this.data, null, 2), "utf-8");
        chmodSync(this.authPath, 0o600);
    }
    /**
     * Get credential for a provider.
     */
    get(provider) {
        return this.data[provider] ?? undefined;
    }
    /**
     * Set credential for a provider.
     */
    set(provider, credential) {
        this.data[provider] = credential;
        this.save();
    }
    /**
     * Remove credential for a provider.
     */
    remove(provider) {
        delete this.data[provider];
        this.save();
    }
    /**
     * List all providers with credentials.
     */
    list() {
        return Object.keys(this.data);
    }
    /**
     * Check if credentials exist for a provider in auth.json.
     */
    has(provider) {
        return provider in this.data;
    }
    /**
     * Check if any form of auth is configured for a provider.
     * Unlike getApiKey(), this doesn't refresh OAuth tokens.
     */
    hasAuth(provider) {
        if (this.runtimeOverrides.has(provider))
            return true;
        if (this.data[provider])
            return true;
        if (getEnvApiKey(provider))
            return true;
        if (this.fallbackResolver?.(provider))
            return true;
        return false;
    }
    /**
     * Get all credentials (for passing to getOAuthApiKey).
     */
    getAll() {
        return { ...this.data };
    }
    /**
     * Login to an OAuth provider.
     */
    async login(providerId, callbacks) {
        const provider = getOAuthProvider(providerId);
        if (!provider) {
            throw new Error(`Unknown OAuth provider: ${providerId}`);
        }
        const credentials = await provider.login(callbacks);
        this.set(providerId, { type: "oauth", ...credentials });
    }
    /**
     * Logout from a provider.
     */
    logout(provider) {
        this.remove(provider);
    }
    /**
     * Refresh OAuth token with file locking to prevent race conditions.
     * Multiple pi instances may try to refresh simultaneously when tokens expire.
     * This ensures only one instance refreshes while others wait and use the result.
     */
    async refreshOAuthTokenWithLock(providerId) {
        const provider = getOAuthProvider(providerId);
        if (!provider) {
            return null;
        }
        // Ensure auth file exists for locking
        if (!existsSync(this.authPath)) {
            const dir = dirname(this.authPath);
            if (!existsSync(dir)) {
                mkdirSync(dir, { recursive: true, mode: 0o700 });
            }
            writeFileSync(this.authPath, "{}", "utf-8");
            chmodSync(this.authPath, 0o600);
        }
        let release;
        try {
            // Acquire exclusive lock with retry and timeout
            // Use generous retry window to handle slow token endpoints
            release = await lockfile.lock(this.authPath, {
                retries: {
                    retries: 10,
                    factor: 2,
                    minTimeout: 100,
                    maxTimeout: 10000,
                    randomize: true,
                },
                stale: 30000, // Consider lock stale after 30 seconds
            });
            // Re-read file after acquiring lock - another instance may have refreshed
            this.reload();
            const cred = this.data[providerId];
            if (cred?.type !== "oauth") {
                return null;
            }
            // Check if token is still expired after re-reading
            // (another instance may have already refreshed it)
            if (Date.now() < cred.expires) {
                // Token is now valid - another instance refreshed it
                const apiKey = provider.getApiKey(cred);
                return { apiKey, newCredentials: cred };
            }
            // Token still expired, we need to refresh
            const oauthCreds = {};
            for (const [key, value] of Object.entries(this.data)) {
                if (value.type === "oauth") {
                    oauthCreds[key] = value;
                }
            }
            const result = await getOAuthApiKey(providerId, oauthCreds);
            if (result) {
                this.data[providerId] = { type: "oauth", ...result.newCredentials };
                this.save();
                return result;
            }
            return null;
        }
        finally {
            // Always release the lock
            if (release) {
                try {
                    await release();
                }
                catch {
                    // Ignore unlock errors (lock may have been compromised)
                }
            }
        }
    }
    /**
     * Get API key for a provider.
     * Priority:
     * 1. Runtime override (CLI --api-key)
     * 2. API key from auth.json
     * 3. OAuth token from auth.json (auto-refreshed with locking)
     * 4. Environment variable
     * 5. Fallback resolver (models.json custom providers)
     */
    async getApiKey(providerId) {
        // Runtime override takes highest priority
        const runtimeKey = this.runtimeOverrides.get(providerId);
        if (runtimeKey) {
            return runtimeKey;
        }
        const cred = this.data[providerId];
        if (cred?.type === "api_key") {
            return resolveConfigValue(cred.key);
        }
        if (cred?.type === "oauth") {
            const provider = getOAuthProvider(providerId);
            if (!provider) {
                // Unknown OAuth provider, can't get API key
                return undefined;
            }
            // Check if token needs refresh
            const needsRefresh = Date.now() >= cred.expires;
            if (needsRefresh) {
                // Use locked refresh to prevent race conditions
                try {
                    const result = await this.refreshOAuthTokenWithLock(providerId);
                    if (result) {
                        return result.apiKey;
                    }
                }
                catch {
                    // Refresh failed - re-read file to check if another instance succeeded
                    this.reload();
                    const updatedCred = this.data[providerId];
                    if (updatedCred?.type === "oauth" && Date.now() < updatedCred.expires) {
                        // Another instance refreshed successfully, use those credentials
                        return provider.getApiKey(updatedCred);
                    }
                    // Refresh truly failed - return undefined so model discovery skips this provider
                    // User can /login to re-authenticate (credentials preserved for retry)
                    return undefined;
                }
            }
            else {
                // Token not expired, use current access token
                return provider.getApiKey(cred);
            }
        }
        // Fall back to environment variable
        const envKey = getEnvApiKey(providerId);
        if (envKey)
            return envKey;
        // Fall back to custom resolver (e.g., models.json custom providers)
        return this.fallbackResolver?.(providerId) ?? undefined;
    }
    /**
     * Get all registered OAuth providers
     */
    getOAuthProviders() {
        return getOAuthProviders();
    }
}
//# sourceMappingURL=auth-storage.js.map