import fs from "node:fs/promises";
import crypto from "node:crypto";
import { getAppPaths } from "../config.js";
import { refreshAnthropicToken } from "./oauth/anthropic.js";
import { refreshOpenAIToken } from "./oauth/openai.js";
import { refreshGeminiToken } from "./oauth/gemini.js";
import { refreshKimiToken } from "./oauth/kimi.js";
import { withFileLock } from "./file-lock.js";
import { log } from "./logger.js";
/**
 * Storage key for Kimi Code OAuth credentials. Kept distinct from the
 * `moonshot` API-key entry so a user can configure BOTH and we always
 * prefer OAuth for the logical `moonshot` provider.
 */
export const MOONSHOT_OAUTH_KEY = "moonshot-oauth";
/** Providers whose credentials are static API keys (no refresh mechanism). */
const STATIC_API_KEY_PROVIDERS = new Set([
    "glm",
    "moonshot",
    "xiaomi",
    "minimax",
    "deepseek",
    "openrouter",
]);
export class AuthStorage {
    data = {};
    filePath;
    loaded = false;
    /** Per-provider lock to serialize concurrent refresh calls. */
    refreshLocks = new Map();
    constructor(filePath) {
        this.filePath = filePath ?? getAppPaths().authFile;
    }
    /** Path to the on-disk auth file. Useful for status output. */
    get path() {
        return this.filePath;
    }
    /** List provider keys with stored credentials. */
    async listProviders() {
        await this.ensureLoaded();
        return Object.keys(this.data);
    }
    /** True if credentials exist for `provider`. */
    async hasCredentials(provider) {
        await this.ensureLoaded();
        return Boolean(this.data[provider]);
    }
    /**
     * True if the user has any usable auth for the logical provider. For
     * `moonshot` this is satisfied by either the Kimi OAuth credential or the
     * Moonshot API key.
     */
    async hasProviderAuth(provider) {
        await this.ensureLoaded();
        if (provider === "moonshot") {
            return Boolean(this.data[MOONSHOT_OAUTH_KEY] || this.data["moonshot"]);
        }
        return Boolean(this.data[provider]);
    }
    /**
     * True if the active credential for `provider` is a static API key with no
     * refresh mechanism. For `moonshot` this is only true when the Kimi OAuth
     * credential is absent (a present OAuth credential is refreshable).
     */
    async isStaticApiKey(provider) {
        await this.ensureLoaded();
        if (provider === "moonshot" && this.data[MOONSHOT_OAUTH_KEY]) {
            return false;
        }
        return STATIC_API_KEY_PROVIDERS.has(provider);
    }
    async load() {
        await withFileLock(this.filePath, async () => {
            try {
                const content = await fs.readFile(this.filePath, "utf-8");
                this.data = JSON.parse(content);
                log("INFO", "auth", `Loaded credentials from ${this.filePath}`, {
                    providers: Object.keys(this.data).join(",") || "(none)",
                });
            }
            catch (err) {
                this.data = {};
                const code = err.code;
                if (code === "ENOENT") {
                    log("INFO", "auth", `No auth file found at ${this.filePath} (first run)`);
                }
                else {
                    log("ERROR", "auth", `Failed to load auth file: ${err instanceof Error ? err.message : String(err)}`, { path: this.filePath, code: code ?? "unknown" });
                }
            }
        });
        this.loaded = true;
    }
    async ensureLoaded() {
        if (!this.loaded)
            await this.load();
    }
    async getCredentials(provider) {
        await this.ensureLoaded();
        return this.data[provider];
    }
    async setCredentials(provider, creds) {
        await this.ensureLoaded();
        this.data[provider] = creds;
        await this.save();
    }
    async clearCredentials(provider) {
        await this.ensureLoaded();
        delete this.data[provider];
        await this.save();
    }
    async clearAll() {
        this.data = {};
        await this.save();
    }
    /**
     * Returns valid credentials, auto-refreshing if expired.
     * If `forceRefresh` is true, refreshes even if the token hasn't expired
     * (useful when the provider rejects a token with 401 before its stored expiry).
     * Throws if not logged in.
     */
    async resolveCredentials(provider, opts) {
        await this.ensureLoaded();
        // Prefer Kimi OAuth over the Moonshot API key for the logical `moonshot`
        // provider. When an OAuth credential exists, resolve (and refresh) that
        // instead — this is the "default to OAuth first" rule.
        if (provider === "moonshot" && this.data[MOONSHOT_OAUTH_KEY]) {
            try {
                return await this.resolveCredentials(MOONSHOT_OAUTH_KEY, opts);
            }
            catch (err) {
                // OAuth refresh token is dead and was wiped. Fall back to the
                // Moonshot API key if the user also configured one.
                if (err instanceof NotLoggedInError && this.data["moonshot"]) {
                    return this.data["moonshot"];
                }
                throw err;
            }
        }
        const creds = this.data[provider];
        if (!creds) {
            throw new NotLoggedInError(provider);
        }
        // Static API-key providers have no refresh mechanism. The Kimi OAuth key
        // (MOONSHOT_OAUTH_KEY) is intentionally excluded — it refreshes below.
        if (STATIC_API_KEY_PROVIDERS.has(provider)) {
            return creds;
        }
        // Return if not expired and not force-refreshing
        if (!opts?.forceRefresh && Date.now() < creds.expiresAt) {
            return creds;
        }
        // Serialize concurrent refresh calls per provider to avoid races
        const existing = this.refreshLocks.get(provider);
        if (existing)
            return existing;
        const refreshPromise = withFileLock(this.filePath, async () => {
            // Re-read from disk in case another process refreshed while we waited for the lock
            try {
                const content = await fs.readFile(this.filePath, "utf-8");
                const freshData = JSON.parse(content);
                const freshCreds = freshData[provider];
                if (freshCreds && !opts?.forceRefresh && Date.now() < freshCreds.expiresAt) {
                    // Another process already refreshed — use their token
                    this.data[provider] = freshCreds;
                    return freshCreds;
                }
            }
            catch {
                // Fall through to refresh
            }
            const refreshFn = provider === "anthropic"
                ? refreshAnthropicToken
                : provider === "gemini"
                    ? refreshGeminiToken
                    : provider === MOONSHOT_OAUTH_KEY
                        ? refreshKimiToken
                        : refreshOpenAIToken;
            let refreshed;
            try {
                refreshed = await refreshFn(creds.refreshToken);
            }
            catch (err) {
                // Refresh token revoked / expired / invalid → the stored creds are
                // unusable. Wipe them so the next launch surfaces a clean
                // NotLoggedInError instead of hitting the same dead refresh path
                // every time. The user must re-login.
                const msg = err instanceof Error ? err.message : String(err);
                const isAuthFailure = /\((401|400)\)/.test(msg) ||
                    /invalid_grant|invalid_token|invalid.*refresh/i.test(msg) ||
                    /unauthorized/i.test(msg);
                if (isAuthFailure) {
                    delete this.data[provider];
                    await atomicWriteFile(this.filePath, JSON.stringify(this.data, null, 2));
                    throw new NotLoggedInError(provider);
                }
                throw err;
            }
            if (!refreshed.accountId && creds.accountId) {
                refreshed.accountId = creds.accountId;
            }
            if (!refreshed.projectId && creds.projectId) {
                refreshed.projectId = creds.projectId;
            }
            this.data[provider] = refreshed;
            // Write atomically (we already hold the file lock)
            await atomicWriteFile(this.filePath, JSON.stringify(this.data, null, 2));
            return refreshed;
        });
        this.refreshLocks.set(provider, refreshPromise);
        try {
            return await refreshPromise;
        }
        finally {
            this.refreshLocks.delete(provider);
        }
    }
    /**
     * Returns a valid access token, auto-refreshing if expired.
     * Throws if not logged in.
     */
    async resolveToken(provider) {
        const creds = await this.resolveCredentials(provider);
        return creds.accessToken;
    }
    async save() {
        await withFileLock(this.filePath, async () => {
            await atomicWriteFile(this.filePath, JSON.stringify(this.data, null, 2));
        });
    }
}
/**
 * Atomic file write using temp file + rename pattern.
 * Prevents partial/corrupt data if the process crashes mid-write.
 */
async function atomicWriteFile(filePath, content) {
    const tmpPath = `${filePath}.${process.pid}.${Date.now()}.${crypto.randomUUID().slice(0, 8)}.tmp`;
    try {
        await fs.writeFile(tmpPath, content, { encoding: "utf-8", mode: 0o600 });
        await fs.rename(tmpPath, filePath);
    }
    catch (err) {
        await fs.unlink(tmpPath).catch(() => { });
        throw err;
    }
}
export class NotLoggedInError extends Error {
    provider;
    constructor(provider) {
        super(`Not logged in to ${provider}. Run "ezcoder login" to authenticate.`);
        this.name = "NotLoggedInError";
        this.provider = provider;
    }
}
//# sourceMappingURL=auth-storage.js.map