/**
 * 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 { findEnvKeys, getEnvApiKey, } from "@earendil-works/pi-ai/compat";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { join } from "path";
import { getAgentConfigPaths, getAgentDir } from "../config.js";
import { FileAuthStorageBackend, InMemoryAuthStorageBackend } from "./auth-storage-backends.js";
import { resolveConfigValue } from "./resolve-config-value.js";
export { FileAuthStorageBackend, InMemoryAuthStorageBackend } from "./auth-storage-backends.js";
/**
 * Credential storage backed by a JSON file.
 */
export class AuthStorage {
    constructor(storage) {
        this.data = {};
        this.runtimeOverrides = new Map();
        this.loadError = null;
        this.errors = [];
        this.storage = storage;
        this.reload();
    }
    static create(authPath) {
        return new AuthStorage(new FileAuthStorageBackend(authPath ?? join(getAgentDir(), "auth.json"), authPath ? [authPath] : getAgentConfigPaths("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;
    }
    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);
    }
    /**
     * Reload credentials from storage.
     */
    reload() {
        try {
            // Pure read: never take the exclusive write lock. Writers replace
            // auth.json atomically, so a lock-free read always sees a complete
            // snapshot. This keeps many concurrent sessions from starving each other
            // on the lock and misreporting configured providers as unreadable under
            // contention (issue #1431).
            const content = this.readSnapshot();
            this.data = this.parseStorageData(content);
            this.loadError = null;
        }
        catch (error) {
            this.loadError = error;
            this.recordError(error);
        }
    }
    /**
     * Read the credential snapshot, preferring the backend's lock-free `read()`.
     * Falls back to a `withLock`-based read for custom backends that predate
     * `read()` so the released `AuthStorageBackend` interface stays compatible.
     */
    readSnapshot() {
        if (this.storage.read) {
            return this.storage.read();
        }
        let content;
        this.storage.withLock((current) => {
            content = current;
            return { result: undefined };
        });
        return content;
    }
    persistProviderChange(provider, credential) {
        if (this.loadError) {
            this.reload();
            if (this.loadError)
                throw this.loadError;
        }
        try {
            const persistedData = this.storage.withLock((current) => {
                const currentData = this.parseStorageData(current);
                const merged = { ...currentData };
                if (credential) {
                    merged[provider] = credential;
                }
                else {
                    delete merged[provider];
                }
                return { result: merged, next: JSON.stringify(merged, null, 2) };
            });
            this.data = persistedData;
            this.loadError = null;
        }
        catch (error) {
            this.recordError(error);
            throw error instanceof Error ? error : new Error(String(error));
        }
    }
    /**
     * Get credential for a provider.
     */
    get(provider) {
        return this.data[provider] ?? undefined;
    }
    /**
     * Set credential for a provider.
     */
    set(provider, credential) {
        this.persistProviderChange(provider, credential);
    }
    /**
     * Remove credential for a provider.
     */
    remove(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;
    }
    /**
     * Return auth status without exposing credential values or refreshing tokens.
     */
    getAuthStatus(provider) {
        if (this.data[provider]) {
            return { configured: true, source: "stored" };
        }
        if (this.runtimeOverrides.has(provider)) {
            return { configured: false, source: "runtime", label: "--api-key" };
        }
        const envKeys = findEnvKeys(provider);
        if (envKeys?.[0]) {
            return { configured: false, source: "environment", label: envKeys[0] };
        }
        if (this.fallbackResolver?.(provider)) {
            return { configured: false, source: "fallback", label: "custom provider config" };
        }
        return { configured: false };
    }
    /**
     * Get all credentials (for passing to getOAuthApiKey).
     */
    getAll() {
        return { ...this.data };
    }
    drainErrors() {
        const drained = [...this.errors];
        this.errors = [];
        return drained;
    }
    /**
     * Returns the error from the most recent failed credential load, or null when
     * the last reload succeeded.
     *
     * A non-null value means stored credentials could NOT be read — e.g. the auth
     * file was temporarily locked by another process (ELOCKED) or contained
     * invalid JSON — so an empty/absent credential set is NOT authoritative.
     * Callers that would otherwise report "No API key found" should surface this
     * load failure instead of treating the provider as unauthenticated
     * (issue #1431).
     */
    getLoadError() {
        return this.loadError;
    }
    /**
     * 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 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;
        }
        let synchronizedData;
        const result = await this.storage.withLockAsync(async (current) => {
            const currentData = this.parseStorageData(current);
            synchronizedData = currentData;
            const cred = currentData[providerId];
            if (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)) {
                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 },
            };
            synchronizedData = merged;
            return { result: refreshed, next: JSON.stringify(merged, null, 2) };
        });
        if (synchronizedData)
            this.data = synchronizedData;
        this.loadError = null;
        return result;
    }
    /**
     * 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, options) {
        // 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 (error) {
                    this.recordError(error);
                    // 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)
        if (options?.includeFallback !== false) {
            return this.fallbackResolver?.(providerId) ?? undefined;
        }
        return undefined;
    }
    /**
     * Get all registered OAuth providers
     */
    getOAuthProviders() {
        return getOAuthProviders();
    }
}
//# sourceMappingURL=auth-storage.js.map