/**
 * 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 { join } from "path";
import { getAgentConfigPaths, getAgentDir } from "../config.js";
import { FileAuthStorageBackend, InMemoryAuthStorageBackend } from "./auth-storage-backends.js";
import { getOAuthProviderDescriptors, loginOAuthProvider, oauthCredentialToAuth, refreshOAuthProvider, } from "./oauth-provider-bridge.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.credentialMutationTail = Promise.resolve();
        this.credentialVersions = new Map();
        this.storage = storage;
        this.reload();
    }
    static create(authPath) {
        const paths = authPath === undefined
            ? getAgentConfigPaths("auth.json")
            : Array.isArray(authPath) ? authPath : [authPath];
        return new AuthStorage(new FileAuthStorageBackend(paths[0] ?? join(getAgentDir(), "auth.json"), paths));
    }
    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);
    }
    /** Read a non-persisted request override without resolving stored credentials. */
    getRuntimeApiKey(provider) {
        return this.runtimeOverrides.get(provider);
    }
    /**
     * 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;
    }
    bumpCredentialVersion(provider) {
        this.credentialVersions.set(provider, (this.credentialVersions.get(provider) ?? 0) + 1);
    }
    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.bumpCredentialVersion(provider);
            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 };
    }
    /** Return a copy of all persisted credentials. */
    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 credential = await loginOAuthProvider(providerId, callbacks);
        await this.asCredentialStore().modify(providerId, async () => credential);
    }
    /** Logout through the preserved synchronous persistence path. */
    logout(provider) {
        this.remove(provider);
    }
    /** Serialized logout for callers that need persistence completion. */
    async logoutAsync(provider) {
        await this.asCredentialStore().delete(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) {
        return this.runCredentialMutation(async () => {
            let synchronizedData;
            const credentialVersion = this.credentialVersions.get(providerId) ?? 0;
            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 };
                const oauthCredential = { ...cred, type: "oauth" };
                if (Date.now() < cred.expires) {
                    const auth = await oauthCredentialToAuth(providerId, oauthCredential);
                    return { result: auth ? { auth, newCredentials: cred } : null };
                }
                const refreshed = await refreshOAuthProvider(providerId, oauthCredential);
                if (!refreshed)
                    return { result: null };
                if ((this.credentialVersions.get(providerId) ?? 0) !== credentialVersion) {
                    synchronizedData = undefined;
                    return { result: null };
                }
                const merged = {
                    ...currentData,
                    [providerId]: refreshed.credential,
                };
                synchronizedData = merged;
                return {
                    result: { auth: refreshed.auth, newCredentials: refreshed.credential },
                    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 getModelAuth(providerId, options) {
        const runtimeKey = this.runtimeOverrides.get(providerId);
        if (runtimeKey)
            return { apiKey: runtimeKey };
        const cred = this.data[providerId];
        if (cred?.type === "api_key")
            return { apiKey: resolveConfigValue(cred.key) };
        if (cred?.type === "oauth") {
            try {
                if (Date.now() >= cred.expires) {
                    return (await this.refreshOAuthTokenWithLock(providerId))?.auth;
                }
                return await oauthCredentialToAuth(providerId, { ...cred, type: "oauth" });
            }
            catch (error) {
                this.recordError(error);
                this.reload();
                const updated = this.data[providerId];
                if (updated?.type === "oauth" && Date.now() < updated.expires) {
                    return oauthCredentialToAuth(providerId, { ...updated, type: "oauth" });
                }
                return undefined;
            }
        }
        const envKey = getEnvApiKey(providerId);
        if (envKey)
            return { apiKey: envKey };
        if (options?.includeFallback !== false) {
            const fallback = this.fallbackResolver?.(providerId);
            if (fallback)
                return { apiKey: fallback };
        }
        return undefined;
    }
    async getApiKey(providerId, options) {
        return (await this.getModelAuth(providerId, options))?.apiKey;
    }
    /**
     * Get all registered OAuth providers
     */
    getOAuthProviders() {
        return getOAuthProviderDescriptors();
    }
    runCredentialMutation(operation) {
        const result = this.credentialMutationTail.then(operation, operation);
        this.credentialMutationTail = result.then(() => undefined, () => undefined);
        return result;
    }
    /** Private async adapter for pi-ai's provider-owned Models runtime. */
    asCredentialStore() {
        const runtimeCredential = (providerId) => {
            const key = this.runtimeOverrides.get(providerId);
            return key === undefined ? undefined : { type: "api_key", key };
        };
        return {
            read: async (providerId) => runtimeCredential(providerId) ?? this.get(providerId),
            list: async () => {
                const providers = new Set([...this.list(), ...this.runtimeOverrides.keys()]);
                return [...providers].map((providerId) => ({
                    providerId,
                    type: runtimeCredential(providerId)?.type ?? this.data[providerId].type,
                }));
            },
            modify: (providerId, fn) => this.runCredentialMutation(async () => {
                let synchronizedData;
                try {
                    const result = await this.storage.withLockAsync(async (current) => {
                        const data = this.parseStorageData(current);
                        const next = await fn(data[providerId]);
                        if (next === undefined) {
                            synchronizedData = data;
                            return { result: data[providerId] };
                        }
                        const merged = { ...data, [providerId]: next };
                        synchronizedData = merged;
                        return { result: next, next: JSON.stringify(merged, null, 2) };
                    });
                    if (synchronizedData) {
                        this.data = synchronizedData;
                        this.bumpCredentialVersion(providerId);
                    }
                    this.loadError = null;
                    return result;
                }
                catch (error) {
                    this.recordError(error);
                    throw error;
                }
            }),
            delete: (providerId) => this.runCredentialMutation(async () => {
                let synchronizedData;
                try {
                    await this.storage.withLockAsync(async (current) => {
                        const data = this.parseStorageData(current);
                        delete data[providerId];
                        synchronizedData = data;
                        return { result: undefined, next: JSON.stringify(data, null, 2) };
                    });
                    if (synchronizedData) {
                        this.data = synchronizedData;
                        this.bumpCredentialVersion(providerId);
                    }
                    this.loadError = null;
                }
                catch (error) {
                    this.recordError(error);
                    throw error;
                }
            }),
        };
    }
}
//# sourceMappingURL=auth-storage.js.map