/**
 * Credential storage for API keys and OAuth tokens.
 * Handles loading, saving, and refreshing credentials from auth.json.
 *
 * Supports multiple credentials per provider with round-robin selection,
 * session-sticky hashing, and automatic rate-limit fallback.
 *
 * Uses file locking to prevent race conditions when multiple pi instances
 * try to refresh tokens simultaneously.
 */
import { getEnvApiKey, } from "@nerf/pi-ai";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@nerf/pi-ai/oauth";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import { getAgentDir } from "../config.js";
import { AUTH_LOCK_STALE_MS } from "./constants.js";
import { acquireLockAsync, acquireLockSyncWithRetry } from "./lock-utils.js";
import { resolveConfigValue } from "./resolve-config-value.js";
export class FileAuthStorageBackend {
    constructor(authPath = join(getAgentDir(), "auth.json")) {
        this.authPath = authPath;
    }
    ensureParentDir() {
        const dir = dirname(this.authPath);
        if (!existsSync(dir)) {
            mkdirSync(dir, { recursive: true, mode: 0o700 });
        }
    }
    ensureFileExists() {
        if (!existsSync(this.authPath)) {
            writeFileSync(this.authPath, "{}", "utf-8");
            chmodSync(this.authPath, 0o600);
        }
    }
    withLock(fn) {
        this.ensureParentDir();
        this.ensureFileExists();
        let release;
        try {
            release = acquireLockSyncWithRetry(this.authPath);
            const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
            const { result, next } = fn(current);
            if (next !== undefined) {
                writeFileSync(this.authPath, next, "utf-8");
                chmodSync(this.authPath, 0o600);
            }
            return result;
        }
        finally {
            if (release) {
                release();
            }
        }
    }
    async withLockAsync(fn) {
        this.ensureParentDir();
        this.ensureFileExists();
        let release;
        let lockCompromised = false;
        let lockCompromisedError;
        const throwIfCompromised = () => {
            if (lockCompromised) {
                throw lockCompromisedError ?? new Error("Auth storage lock was compromised");
            }
        };
        try {
            release = await acquireLockAsync(this.authPath, {
                staleMs: AUTH_LOCK_STALE_MS,
                onCompromised: (err) => {
                    lockCompromised = true;
                    lockCompromisedError = err;
                },
            });
            throwIfCompromised();
            const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
            const { result, next } = await fn(current);
            throwIfCompromised();
            if (next !== undefined) {
                writeFileSync(this.authPath, next, "utf-8");
                chmodSync(this.authPath, 0o600);
            }
            throwIfCompromised();
            return result;
        }
        finally {
            if (release) {
                try {
                    await release();
                }
                catch {
                    // Ignore unlock errors when lock is compromised.
                }
            }
        }
    }
}
export class InMemoryAuthStorageBackend {
    withLock(fn) {
        const { result, next } = fn(this.value);
        if (next !== undefined) {
            this.value = next;
        }
        return result;
    }
    async withLockAsync(fn) {
        const { result, next } = await fn(this.value);
        if (next !== undefined) {
            this.value = next;
        }
        return result;
    }
}
// ============================================================================
// Backoff durations for different error types (milliseconds)
// ============================================================================
const BACKOFF_RATE_LIMIT_MS = 30_000; // 30s for rate limit / 429
const BACKOFF_QUOTA_EXHAUSTED_MS = 30 * 60_000; // 30min for quota exhausted
const BACKOFF_SERVER_ERROR_MS = 20_000; // 20s for 5xx server errors
const BACKOFF_DEFAULT_MS = 60_000; // 60s fallback
/**
 * Get backoff duration for an error type.
 */
function getBackoffDuration(errorType) {
    switch (errorType) {
        case "rate_limit":
            return BACKOFF_RATE_LIMIT_MS;
        case "quota_exhausted":
            return BACKOFF_QUOTA_EXHAUSTED_MS;
        case "server_error":
            return BACKOFF_SERVER_ERROR_MS;
        default:
            return BACKOFF_DEFAULT_MS;
    }
}
/**
 * Simple string hash for session-sticky credential selection.
 * Returns a positive integer.
 */
function hashString(str) {
    let hash = 0;
    for (let i = 0; i < str.length; i++) {
        const char = str.charCodeAt(i);
        hash = ((hash << 5) - hash + char) | 0;
    }
    return Math.abs(hash);
}
/**
 * Credential storage backed by a JSON file.
 * Supports multiple credentials per provider with round-robin rotation and rate-limit fallback.
 */
export class AuthStorage {
    constructor(storage) {
        this.storage = storage;
        this.data = {};
        this.runtimeOverrides = new Map();
        this.loadError = null;
        this.errors = [];
        this.credentialChangeListeners = new Set();
        /**
         * Round-robin index per provider. Incremented on each call to getApiKey
         * when no sessionId is provided.
         */
        this.providerRoundRobinIndex = new Map();
        /**
         * Backoff tracking per provider per credential index.
         * Map<provider, Map<credentialIndex, backoffExpiresAt>>
         */
        this.credentialBackoff = new Map();
        /**
         * Provider-level backoff tracking.
         * Set when all credentials for a provider are backed off.
         * Map<provider, backoffExpiresAt>
         */
        this.providerBackoff = new Map();
        this.reload();
    }
    static create(authPath) {
        return new AuthStorage(new FileAuthStorageBackend(authPath ?? join(getAgentDir(), "auth.json")));
    }
    static fromStorage(storage) {
        return new AuthStorage(storage);
    }
    static inMemory(data = {}) {
        const storage = new InMemoryAuthStorageBackend();
        storage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) }));
        return AuthStorage.fromStorage(storage);
    }
    /**
     * 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;
    }
    /**
     * Register a callback to be notified when credentials change (e.g., after OAuth token refresh).
     * Returns a function to unregister the listener.
     */
    onCredentialChange(listener) {
        this.credentialChangeListeners.add(listener);
        return () => this.credentialChangeListeners.delete(listener);
    }
    notifyCredentialChange() {
        for (const listener of this.credentialChangeListeners) {
            try {
                listener();
            }
            catch {
                // Don't let listener errors break the refresh flow
            }
        }
    }
    recordError(error) {
        const normalizedError = error instanceof Error ? error : new Error(String(error));
        this.errors.push(normalizedError);
    }
    parseStorageData(content) {
        if (!content) {
            return {};
        }
        return JSON.parse(content);
    }
    /**
     * Normalize a storage entry to an array of credentials.
     * Handles both single credential (backward compat) and array formats.
     */
    getCredentialsForProvider(provider) {
        const entry = this.data[provider];
        if (!entry)
            return [];
        if (Array.isArray(entry))
            return entry;
        return [entry];
    }
    /**
     * Reload credentials from storage.
     */
    reload() {
        let content;
        try {
            this.storage.withLock((current) => {
                content = current;
                return { result: undefined };
            });
            this.data = this.parseStorageData(content);
            this.loadError = null;
        }
        catch (error) {
            this.loadError = error;
            this.recordError(error);
        }
    }
    persistProviderChange(provider, credential) {
        if (this.loadError) {
            return;
        }
        try {
            this.storage.withLock((current) => {
                const currentData = this.parseStorageData(current);
                const merged = { ...currentData };
                if (credential) {
                    merged[provider] = credential;
                }
                else {
                    delete merged[provider];
                }
                return { result: undefined, next: JSON.stringify(merged, null, 2) };
            });
        }
        catch (error) {
            this.recordError(error);
        }
    }
    /**
     * Get the first credential for a provider (backward-compatible).
     */
    get(provider) {
        const creds = this.getCredentialsForProvider(provider);
        return creds[0] ?? undefined;
    }
    /**
     * Set credential for a provider. For API key credentials, appends to
     * existing credentials (accumulation on duplicate login). For OAuth,
     * replaces (only one OAuth token per provider makes sense).
     */
    set(provider, credential) {
        if (credential.type === "api_key") {
            const existing = this.getCredentialsForProvider(provider);
            // Deduplicate: don't add if same key already exists
            const isDuplicate = existing.some((c) => c.type === "api_key" && c.key === credential.key);
            if (isDuplicate)
                return;
            const updated = [...existing, credential];
            this.data[provider] = updated.length === 1 ? updated[0] : updated;
            this.persistProviderChange(provider, updated.length === 1 ? updated[0] : updated);
        }
        else {
            // OAuth: replace any existing OAuth credential, keep API keys
            const existing = this.getCredentialsForProvider(provider);
            const apiKeys = existing.filter((c) => c.type === "api_key");
            if (apiKeys.length === 0) {
                this.data[provider] = credential;
                this.persistProviderChange(provider, credential);
            }
            else {
                const updated = [...apiKeys, credential];
                this.data[provider] = updated;
                this.persistProviderChange(provider, updated);
            }
        }
    }
    /**
     * Remove all credentials for a provider.
     */
    remove(provider) {
        delete this.data[provider];
        this.providerRoundRobinIndex.delete(provider);
        this.credentialBackoff.delete(provider);
        this.providerBackoff.delete(provider);
        this.persistProviderChange(provider, undefined);
    }
    /**
     * 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).
     * Returns normalized format where each provider has a single credential
     * (the first one) for backward compatibility with OAuth refresh.
     *
     * NOTE: For providers with multiple API keys, only the first credential is
     * returned. This is intentional — callers use this for OAuth refresh only,
     * which is always single-credential. Do not use for API key enumeration.
     */
    getAll() {
        const result = {};
        for (const [provider, entry] of Object.entries(this.data)) {
            result[provider] = Array.isArray(entry) ? entry[0] : entry;
        }
        return result;
    }
    drainErrors() {
        const drained = [...this.errors];
        this.errors = [];
        return drained;
    }
    /**
     * 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);
    }
    /**
     * Returns true when the provider has credentials configured but all of them
     * are currently in a backoff window (e.g. rate-limited or quota exhausted).
     * Returns false when there are no credentials or at least one is available.
     */
    areAllCredentialsBackedOff(provider) {
        const credentials = this.getCredentialsForProvider(provider);
        if (credentials.length === 0)
            return false;
        for (let i = 0; i < credentials.length; i++) {
            if (!this.isCredentialBackedOff(provider, i))
                return false;
        }
        return true;
    }
    /**
     * Mark an entire provider as exhausted.
     * Called when all credentials for a provider are backed off.
     */
    markProviderExhausted(provider, errorType) {
        const backoffMs = getBackoffDuration(errorType);
        this.providerBackoff.set(provider, Date.now() + backoffMs);
    }
    /**
     * Check if a provider is currently available (not backed off at provider level).
     */
    isProviderAvailable(provider) {
        const expiresAt = this.providerBackoff.get(provider);
        if (expiresAt === undefined)
            return true;
        if (Date.now() >= expiresAt) {
            this.providerBackoff.delete(provider);
            return true;
        }
        return false;
    }
    /**
     * Get milliseconds remaining until provider backoff expires.
     * Returns 0 if provider is available.
     */
    getProviderBackoffRemaining(provider) {
        const expiresAt = this.providerBackoff.get(provider);
        if (expiresAt === undefined)
            return 0;
        const remaining = expiresAt - Date.now();
        if (remaining <= 0) {
            this.providerBackoff.delete(provider);
            return 0;
        }
        return remaining;
    }
    /**
     * Check if a credential index is currently backed off.
     */
    isCredentialBackedOff(provider, index) {
        const providerBackoff = this.credentialBackoff.get(provider);
        if (!providerBackoff)
            return false;
        const expiresAt = providerBackoff.get(index);
        if (expiresAt === undefined)
            return false;
        if (Date.now() >= expiresAt) {
            providerBackoff.delete(index);
            return false;
        }
        return true;
    }
    /**
     * Select the best credential index for a provider.
     * - If sessionId is provided, uses session-sticky hashing as the starting point.
     * - Otherwise, uses round-robin as the starting point.
     * - Skips credentials that are currently backed off.
     * - Returns -1 if all credentials are backed off.
     */
    selectCredentialIndex(provider, credentials, sessionId) {
        if (credentials.length === 0)
            return -1;
        if (credentials.length === 1) {
            return this.isCredentialBackedOff(provider, 0) ? -1 : 0;
        }
        let startIndex;
        if (sessionId) {
            startIndex = hashString(sessionId) % credentials.length;
        }
        else {
            const current = this.providerRoundRobinIndex.get(provider) ?? 0;
            startIndex = current % credentials.length;
            this.providerRoundRobinIndex.set(provider, current + 1);
        }
        // Try starting from the preferred index, wrapping around
        for (let offset = 0; offset < credentials.length; offset++) {
            const index = (startIndex + offset) % credentials.length;
            if (!this.isCredentialBackedOff(provider, index)) {
                return index;
            }
        }
        // All credentials are backed off
        return -1;
    }
    /**
     * Mark a credential as rate-limited. Finds the credential that was most
     * recently used for this provider+session and backs it off.
     *
     * @returns true if another credential is available (caller should retry),
     *          false if all credentials for this provider are backed off.
     */
    markUsageLimitReached(provider, sessionId, options) {
        const credentials = this.getCredentialsForProvider(provider);
        if (credentials.length === 0)
            return false;
        const errorType = options?.errorType ?? "rate_limit";
        // For unknown/transport errors (e.g. connection reset, "terminated"),
        // don't back off the only credential — it would make getApiKey() return
        // undefined and surface a misleading "Authentication failed" message.
        if (errorType === "unknown" && credentials.length === 1) {
            return false;
        }
        const backoffMs = getBackoffDuration(errorType);
        // Determine which credential was just used (same logic as selectCredentialIndex
        // but without incrementing round-robin)
        let usedIndex;
        if (credentials.length === 1) {
            usedIndex = 0;
        }
        else if (sessionId) {
            usedIndex = hashString(sessionId) % credentials.length;
        }
        else {
            // Round-robin was already incremented in getApiKey, so the last-used
            // index is (current - 1). Note: in a concurrent scenario where another
            // getApiKey call fires between the original request and this backoff call,
            // we may back off the wrong credential index. This is acceptable because:
            // (a) pi runs single-threaded event loop, (b) backing off the wrong key
            // is safe — it self-heals when the backoff expires.
            const current = this.providerRoundRobinIndex.get(provider) ?? 0;
            usedIndex = ((current - 1) % credentials.length + credentials.length) % credentials.length;
        }
        // Set backoff for this credential
        let providerBackoff = this.credentialBackoff.get(provider);
        if (!providerBackoff) {
            providerBackoff = new Map();
            this.credentialBackoff.set(provider, providerBackoff);
        }
        providerBackoff.set(usedIndex, Date.now() + backoffMs);
        // Check if any credential is still available
        for (let i = 0; i < credentials.length; i++) {
            if (!this.isCredentialBackedOff(provider, i)) {
                return true;
            }
        }
        return false;
    }
    /**
     * Refresh OAuth token with backend locking to prevent race conditions.
     * Multiple pi instances may try to refresh simultaneously when tokens expire.
     */
    async refreshOAuthTokenWithLock(providerId) {
        const provider = getOAuthProvider(providerId);
        if (!provider) {
            return null;
        }
        const result = await this.storage.withLockAsync(async (current) => {
            const currentData = this.parseStorageData(current);
            this.data = currentData;
            this.loadError = null;
            // Find the OAuth credential for this provider
            const creds = this.getCredentialsForProvider(providerId);
            const cred = creds.find((c) => c.type === "oauth");
            if (!cred || cred.type !== "oauth") {
                return { result: null };
            }
            if (Date.now() < cred.expires) {
                return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } };
            }
            const oauthCreds = {};
            for (const [key, value] of Object.entries(currentData)) {
                const first = Array.isArray(value) ? value.find((c) => c.type === "oauth") : value;
                if (first?.type === "oauth") {
                    oauthCreds[key] = first;
                }
            }
            const refreshed = await getOAuthApiKey(providerId, oauthCreds);
            if (!refreshed) {
                return { result: null };
            }
            // Update the OAuth credential in-place within the array
            const existingEntry = currentData[providerId];
            const newOAuthCred = { type: "oauth", ...refreshed.newCredentials };
            let updatedEntry;
            if (Array.isArray(existingEntry)) {
                updatedEntry = existingEntry.map((c) => (c.type === "oauth" ? newOAuthCred : c));
            }
            else {
                updatedEntry = newOAuthCred;
            }
            const merged = {
                ...currentData,
                [providerId]: updatedEntry,
            };
            this.data = merged;
            this.loadError = null;
            return { result: refreshed, next: JSON.stringify(merged, null, 2) };
        });
        // Notify listeners after credential change (e.g., model registry refresh)
        if (result) {
            queueMicrotask(() => this.notifyCredentialChange());
        }
        return result;
    }
    /**
     * Resolve an API key from a single credential.
     */
    async resolveCredentialApiKey(providerId, cred) {
        if (cred.type === "api_key") {
            return resolveConfigValue(cred.key);
        }
        if (cred.type === "oauth") {
            const provider = getOAuthProvider(providerId);
            if (!provider)
                return undefined;
            const needsRefresh = Date.now() >= cred.expires;
            if (needsRefresh) {
                try {
                    const result = await this.refreshOAuthTokenWithLock(providerId);
                    if (result)
                        return result.apiKey;
                }
                catch (error) {
                    this.recordError(error);
                    this.reload();
                    const updatedCreds = this.getCredentialsForProvider(providerId);
                    const updatedOAuth = updatedCreds.find((c) => c.type === "oauth");
                    if (updatedOAuth?.type === "oauth" && Date.now() < updatedOAuth.expires) {
                        return provider.getApiKey(updatedOAuth);
                    }
                    return undefined;
                }
            }
            else {
                return provider.getApiKey(cred);
            }
        }
        return undefined;
    }
    /**
     * Get API key for a provider.
     * Priority:
     * 1. Runtime override (CLI --api-key)
     * 2. Credential(s) from auth.json (with round-robin / session-sticky selection)
     * 3. Environment variable
     * 4. Fallback resolver (models.json custom providers)
     *
     * @param providerId - The provider to get an API key for
     * @param sessionId - Optional session ID for sticky credential selection
     */
    async getApiKey(providerId, sessionId, options) {
        // If the model has a local baseUrl, return a dummy key to avoid auth blocking
        if (options?.baseUrl) {
            try {
                const hostname = new URL(options.baseUrl).hostname;
                if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "0.0.0.0" || hostname === "::1") {
                    return "local-no-key-needed";
                }
            }
            catch {
                if (options.baseUrl.startsWith("unix:")) {
                    return "local-no-key-needed";
                }
            }
        }
        // Runtime override takes highest priority
        const runtimeKey = this.runtimeOverrides.get(providerId);
        if (runtimeKey) {
            return runtimeKey;
        }
        const credentials = this.getCredentialsForProvider(providerId);
        if (credentials.length > 0) {
            const index = this.selectCredentialIndex(providerId, credentials, sessionId);
            if (index >= 0) {
                const resolved = await this.resolveCredentialApiKey(providerId, credentials[index]);
                if (resolved)
                    return resolved;
                // Credential unresolvable (e.g. type:"oauth" for a non-OAuth provider) —
                // fall through to env / fallback instead of returning undefined (#2083)
            }
            // All credentials backed off or unresolvable - fall through to env/fallback
        }
        // 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