/**
 * Credential storage for OAuth tokens.
 * Handles loading, saving, and refreshing credentials from auth.json.
 *
 * Uses file locking to prevent race conditions when multiple agent instances
 * try to refresh tokens simultaneously.
 */
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import lockfile from 'proper-lockfile';
import { getEnvApiKey } from '../../ai/env-api-keys.js';
import { getOAuthApiKey, getOAuthProvider } from '../../ai/utils/oauth/index.js';
import { getAgentDir } from '../../config.js';
import { LOCK_BASE_OPTIONS, lockSyncWithRetries } from '../../shared/files/lock-utils.js';
export class FileAuthStorageBackend {
    authPath;
    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 = lockSyncWithRetries(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 lockfile.lock(this.authPath, {
                ...LOCK_BASE_OPTIONS,
                retries: {
                    retries: 10,
                    factor: 2,
                    minTimeout: 100,
                    maxTimeout: 10000,
                    randomize: true
                },
                stale: 30000,
                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.
                }
            }
        }
    }
}
/**
 * Read-only file backend for subagents.
 * Reads auth.json without file locking to avoid contention when multiple
 * subagents spawn concurrently. Never writes — the parent process owns
 * token refresh and persists updated credentials.
 */
export class ReadOnlyFileAuthStorageBackend {
    authPath;
    constructor(authPath = join(getAgentDir(), 'auth.json')) {
        this.authPath = authPath;
    }
    withLock(fn) {
        let content;
        try {
            if (existsSync(this.authPath)) {
                content = readFileSync(this.authPath, 'utf-8');
            }
        }
        catch {
            // File may be mid-write by parent — return undefined, caller will retry later
        }
        const { result } = fn(content);
        // Ignore `next` — read-only backend never writes
        return result;
    }
    async withLockAsync(fn) {
        let content;
        try {
            if (existsSync(this.authPath)) {
                content = readFileSync(this.authPath, 'utf-8');
            }
        }
        catch {
            // File may be mid-write by parent — return undefined, caller will retry later
        }
        const { result } = await fn(content);
        // Ignore `next` — read-only backend never writes
        return result;
    }
}
export class InMemoryAuthStorageBackend {
    value;
    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;
    }
}
/**
 * Credential storage backed by a JSON file.
 */
export class AuthStorage {
    storage;
    data = {};
    runtimeOverrides = new Map();
    fallbackResolver;
    loadError = null;
    errors = [];
    _readOnly = false;
    externalCredentialStores;
    constructor(storage, options) {
        this.storage = storage;
        this.externalCredentialStores = options?.externalCredentialStores ?? [];
        this.reload();
    }
    static create(authPathOrOptions) {
        const options = typeof authPathOrOptions === 'string' ? { authPath: authPathOrOptions } : authPathOrOptions;
        return new AuthStorage(new FileAuthStorageBackend(options?.authPath ?? join(getAgentDir(), 'auth.json')), options);
    }
    static fromStorage(storage, options) {
        return new AuthStorage(storage, options);
    }
    /**
     * Create a read-only AuthStorage backed by auth.json.
     * Reads without file locking — safe for concurrent subagents.
     * Never writes or refreshes tokens; call reload() periodically
     * to pick up tokens refreshed by the parent process.
     */
    static createReadOnly(authPathOrOptions) {
        const options = typeof authPathOrOptions === 'string' ? { authPath: authPathOrOptions } : authPathOrOptions;
        const instance = new AuthStorage(new ReadOnlyFileAuthStorageBackend(options?.authPath ?? join(getAgentDir(), 'auth.json')), options);
        instance._readOnly = true;
        return instance;
    }
    static inMemory(data = {}, options) {
        const storage = new InMemoryAuthStorageBackend();
        storage.withLock(() => ({ result: undefined, next: JSON.stringify(data, null, 2) }));
        return AuthStorage.fromStorage(storage, options);
    }
    /**
     * 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;
    }
    getExternalCredentialStore(provider) {
        return this.externalCredentialStores.find((store) => store.owns(provider));
    }
    parseStorageData(content) {
        if (!content) {
            return {};
        }
        const parsed = JSON.parse(content);
        const oauthOnly = {};
        for (const [provider, value] of Object.entries(parsed)) {
            if (value &&
                typeof value === 'object' &&
                value.type === 'oauth' &&
                typeof value.access === 'string' &&
                typeof value.refresh === 'string' &&
                typeof value.expires === 'number') {
                oauthOnly[provider] = value;
            }
        }
        return oauthOnly;
    }
    parseStorageDocument(content) {
        if (!content) {
            return {};
        }
        // Preserve unknown top-level keys so credential writes do not clobber
        // sections owned by auxiliary stores sharing auth.json.
        const parsed = JSON.parse(content);
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
            throw new Error('auth.json must contain a JSON object');
        }
        return { ...parsed };
    }
    recordError(error) {
        const normalizedError = error instanceof Error ? error : new Error(String(error));
        this.errors.push(normalizedError);
    }
    /**
     * Reload credentials from storage.
     */
    reload() {
        let content;
        try {
            this.storage.withLock((current) => {
                content = current;
                return { result: undefined };
            });
            this.data = this.parseStorageData(content);
            for (const store of this.externalCredentialStores) {
                store.reload();
            }
            this.loadError = null;
        }
        catch (error) {
            this.loadError = error;
            this.recordError(error);
        }
    }
    persistProviderChange(provider, credential) {
        const externalStore = this.getExternalCredentialStore(provider);
        if (externalStore) {
            try {
                if (credential) {
                    externalStore.set(provider, credential);
                }
                else {
                    externalStore.remove(provider);
                }
            }
            catch (error) {
                this.recordError(error);
            }
            return;
        }
        if (this.loadError) {
            return;
        }
        try {
            this.storage.withLock((current) => {
                let document;
                try {
                    document = this.parseStorageDocument(current);
                }
                catch {
                    document = {};
                }
                if (credential) {
                    document[provider] = credential;
                }
                else {
                    delete document[provider];
                }
                return {
                    result: undefined,
                    next: JSON.stringify(document, null, 2)
                };
            });
        }
        catch (error) {
            this.recordError(error);
        }
    }
    /**
     * Get credential for a provider.
     */
    get(provider) {
        return this.data[provider] ?? this.getExternalCredentialStore(provider)?.get(provider);
    }
    /**
     * Set credential for a provider.
     */
    set(provider, credential) {
        if (this.getExternalCredentialStore(provider)) {
            delete this.data[provider];
        }
        else {
            this.data[provider] = credential;
        }
        this.persistProviderChange(provider, credential);
    }
    /**
     * Remove credential for a provider.
     */
    remove(provider) {
        delete this.data[provider];
        this.persistProviderChange(provider, undefined);
    }
    /**
     * List all providers with credentials.
     */
    list() {
        return [
            ...new Set([
                ...Object.keys(this.data),
                ...this.externalCredentialStores.flatMap((store) => store.listProviders())
            ])
        ];
    }
    /**
     * Check if stored credentials exist for a provider.
     */
    has(provider) {
        return (this.get(provider) !== undefined ||
            this.getExternalCredentialStore(provider)?.has(provider) === true);
    }
    /**
     * Check if any form of auth is configured for a provider.
     * Only checks OAuth/stored credentials and runtime overrides — ignores
     * environment variables so that e.g. ANTHROPIC_API_KEY doesn't cause
     * Anthropic models to appear when the user hasn't logged in via OAuth.
     */
    hasAuth(provider) {
        if (this.runtimeOverrides.has(provider))
            return true;
        if (this.get(provider))
            return true;
        if (this.fallbackResolver?.(provider))
            return true;
        return false;
    }
    /**
     * Get all credentials (for passing to getOAuthApiKey).
     */
    getAll() {
        const merged = { ...this.data };
        for (const store of this.externalCredentialStores) {
            for (const provider of store.listProviders()) {
                const credential = store.get(provider);
                if (credential) {
                    merged[provider] = credential;
                }
            }
        }
        return merged;
    }
    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);
        // Clear loadError so persistProviderChange doesn't silently skip the write.
        // A successful login produces known-good credentials — safe to persist even
        // if the initial file read failed (corrupt file, lock contention, etc.).
        this.loadError = null;
        this.set(providerId, { type: 'oauth', ...credentials });
    }
    /**
     * Logout from a provider.
     */
    logout(provider) {
        this.remove(provider);
    }
    /**
     * Refresh OAuth token with backend locking to prevent race conditions.
     * Multiple pi instances may try to refresh simultaneously when tokens expire.
     */
    async refreshOAuthTokenWithLock(providerId, options) {
        const provider = getOAuthProvider(providerId);
        if (!provider) {
            return null;
        }
        const forceRefresh = options?.forceRefresh === true;
        const rejectedApiKey = options?.rejectedApiKey;
        const externalStore = this.getExternalCredentialStore(providerId);
        if (externalStore) {
            return externalStore.mutateCredentialWithLock(providerId, async (credential) => {
                if (credential?.type !== 'oauth') {
                    return { result: null };
                }
                if (forceRefresh &&
                    rejectedApiKey &&
                    credential.access !== rejectedApiKey &&
                    Date.now() < credential.expires) {
                    return {
                        result: { apiKey: provider.getApiKey(credential), newCredentials: credential },
                        credential
                    };
                }
                if (!forceRefresh && Date.now() < credential.expires) {
                    return {
                        result: { apiKey: provider.getApiKey(credential), newCredentials: credential },
                        credential
                    };
                }
                const refreshed = await getOAuthApiKey(providerId, {
                    [providerId]: credential
                });
                if (!refreshed) {
                    return { result: null, credential };
                }
                return {
                    result: refreshed,
                    credential: {
                        type: 'oauth',
                        ...refreshed.newCredentials
                    }
                };
            });
        }
        const result = await this.storage.withLockAsync(async (current) => {
            let currentData;
            let document;
            try {
                currentData = this.parseStorageData(current);
                document = this.parseStorageDocument(current);
            }
            catch {
                currentData = {};
                document = {};
            }
            this.data = { ...currentData };
            this.loadError = null;
            const cred = currentData[providerId];
            if (cred?.type !== 'oauth') {
                return { result: null };
            }
            if (forceRefresh &&
                rejectedApiKey &&
                cred.access !== rejectedApiKey &&
                Date.now() < cred.expires) {
                return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } };
            }
            if (!forceRefresh && Date.now() < cred.expires) {
                return { result: { apiKey: provider.getApiKey(cred), newCredentials: cred } };
            }
            const oauthCreds = {};
            for (const [key, value] of Object.entries(currentData)) {
                if (value.type === 'oauth') {
                    oauthCreds[key] = value;
                }
            }
            const refreshed = await getOAuthApiKey(providerId, oauthCreds);
            if (!refreshed) {
                return { result: null };
            }
            const merged = {
                ...currentData,
                [providerId]: { type: 'oauth', ...refreshed.newCredentials }
            };
            this.data = merged;
            this.loadError = null;
            document[providerId] = merged[providerId];
            return {
                result: refreshed,
                next: JSON.stringify(document, null, 2)
            };
        });
        return result;
    }
    /**
     * Mark a provider's token as needing refresh on the next getApiKey() call.
     * Used by the agent loop to force a refresh after a 401 from the backend.
     */
    invalidateToken(providerId) {
        const cred = this.get(providerId);
        if (cred?.type === 'oauth') {
            const externalStore = this.getExternalCredentialStore(providerId);
            if (externalStore) {
                externalStore.set(providerId, { ...cred, expires: 0 });
                return;
            }
            cred.expires = 0;
        }
    }
    /**
     * Get API key for a provider.
     * Priority:
     * 1. Runtime override (CLI --api-key)
     * 2. OAuth token from persisted storage (auto-refreshed with locking)
     * 3. Environment variable
     * 4. Fallback resolver (models.json custom providers)
     */
    async getApiKey(providerId, options) {
        // Runtime override takes highest priority
        const runtimeKey = this.runtimeOverrides.get(providerId);
        if (runtimeKey) {
            return runtimeKey;
        }
        const cred = this.get(providerId);
        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 = options?.forceRefresh === true || Date.now() >= cred.expires;
            if (needsRefresh) {
                if (this._readOnly) {
                    // Read-only mode (subagents): never refresh — re-read the file
                    // to pick up tokens the parent process may have refreshed.
                    this.reload();
                    const updatedCred = this.get(providerId);
                    if (updatedCred?.type === 'oauth') {
                        return provider.getApiKey(updatedCred);
                    }
                    return undefined;
                }
                // Use locked refresh to prevent race conditions
                try {
                    const result = await this.refreshOAuthTokenWithLock(providerId, options);
                    if (result) {
                        return result.apiKey;
                    }
                }
                catch (error) {
                    this.recordError(error);
                    // Refresh failed - re-read file to check if another instance succeeded
                    this.reload();
                    const updatedCred = this.get(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;
    }
}
//# sourceMappingURL=auth-storage.js.map