import { createHash } from "node:crypto";
import { watchFile, unwatchFile } from "node:fs";
import { readFile } from "node:fs/promises";
import { createAccountAllowlist, ENV_ANTHROPIC_ACCOUNT_KEY, isAccountAllowed, LEGACY_ANTHROPIC_ACCOUNT_KEY, normalizeAnthropicAccountKey, } from "./accountSelection.js";
import { ModelRouter } from "./modelRouter.js";
import { loadProxyConfig } from "./proxyConfig.js";
import { logger } from "../utils/logger.js";
const DEFAULT_WATCH_INTERVAL_MS = 1_000;
const DEFAULT_WATCH_DEBOUNCE_MS = 100;
const DEFAULT_SESSION_SOFT_LIMIT = 0.97;
const DEFAULT_SESSION_RESET_TOLERANCE_MS = 15 * 60 * 1_000;
function errorCode(error) {
    let current = error;
    for (let depth = 0; depth < 4; depth += 1) {
        if (!current || typeof current !== "object") {
            return undefined;
        }
        const code = current.code;
        if (typeof code === "string") {
            return code;
        }
        current = current.cause;
    }
    return undefined;
}
function isMissingFileError(error) {
    return errorCode(error) === "ENOENT";
}
function normalizeStrategy(configured, override) {
    if (override) {
        return override;
    }
    return configured === "round-robin" ? "round-robin" : "fill-first";
}
function resolveQuotaRoutingEnabled(envValue, configured) {
    if (envValue === undefined) {
        return configured ?? true;
    }
    const normalized = envValue.trim().toLowerCase();
    return normalized !== "off" && normalized !== "false" && normalized !== "0";
}
function resolveSessionSoftLimit(envValue, configured, rejectInvalid) {
    if (envValue !== undefined) {
        const parsed = Number(envValue);
        if (Number.isFinite(parsed) && parsed > 0 && parsed <= 1) {
            return parsed;
        }
        const message = `Invalid NEUROLINK_PROXY_SESSION_SOFT_LIMIT=${envValue}; ` +
            "expected number in (0, 1]";
        if (rejectInvalid) {
            throw new Error(message);
        }
        logger.warn(`[proxy-config] ${message}; using configured/default value`);
    }
    return configured ?? DEFAULT_SESSION_SOFT_LIMIT;
}
function resolveSessionResetToleranceMs(envValue, configured, rejectInvalid) {
    if (envValue !== undefined) {
        const parsed = Number(envValue);
        if (Number.isInteger(parsed) && parsed > 0) {
            return parsed;
        }
        const message = `Invalid NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS=${envValue}; ` +
            "expected positive integer";
        if (rejectInvalid) {
            throw new Error(message);
        }
        logger.warn(`[proxy-config] ${message}; using configured/default value`);
    }
    return configured ?? DEFAULT_SESSION_RESET_TOLERANCE_MS;
}
async function resolvePrimaryAccountKey(primaryEmail) {
    const trimmed = primaryEmail?.trim();
    if (!trimmed) {
        return undefined;
    }
    const key = normalizeAnthropicAccountKey(trimmed);
    try {
        const { tokenStore } = await import("../auth/tokenStore.js");
        const known = await tokenStore.listByPrefix("anthropic:");
        if (!known.some((knownKey) => normalizeAnthropicAccountKey(knownKey) === key)) {
            logger.warn(`[proxy] WARN: configured routing.primaryAccount=${trimmed} not found in token store; it will activate after authentication`);
        }
    }
    catch (error) {
        logger.debug(`[proxy] could not validate primary account against token store: ${error instanceof Error ? error.message : String(error)}`);
    }
    return key;
}
async function resolveAccountAllowlist(configuredAccounts) {
    const allowlist = createAccountAllowlist(configuredAccounts);
    if (allowlist === undefined) {
        return undefined;
    }
    if (allowlist.size === 0) {
        logger.warn("[proxy] routing.accountAllowlist is empty; all stored Anthropic credentials are denied");
        return allowlist;
    }
    try {
        const { tokenStore } = await import("../auth/tokenStore.js");
        const known = new Set((await tokenStore.listByPrefix("anthropic:")).map(normalizeAnthropicAccountKey));
        known.add(LEGACY_ANTHROPIC_ACCOUNT_KEY);
        known.add(ENV_ANTHROPIC_ACCOUNT_KEY);
        for (const key of allowlist) {
            if (!known.has(key)) {
                logger.warn(`[proxy] WARN: routing.accountAllowlist entry ${key} is allowed but not currently authenticated`);
            }
        }
    }
    catch (error) {
        logger.debug(`[proxy] could not validate account allowlist against token store: ${error instanceof Error ? error.message : String(error)}`);
    }
    return allowlist;
}
function cloneLoadedConfig(loaded) {
    if (!loaded) {
        return null;
    }
    const routing = loaded.routing;
    if (!routing) {
        return Object.freeze({});
    }
    return Object.freeze({
        routing: Object.freeze({
            ...routing,
            ...(routing.modelMappings
                ? {
                    modelMappings: Object.freeze(routing.modelMappings.map((entry) => Object.freeze({ ...entry }))),
                }
                : {}),
            ...(routing.fallbackChain
                ? {
                    fallbackChain: Object.freeze(routing.fallbackChain.map((entry) => Object.freeze({ ...entry }))),
                }
                : {}),
            ...(routing.passthroughModels
                ? { passthroughModels: Object.freeze([...routing.passthroughModels]) }
                : {}),
            ...(routing.accountAllowlist
                ? { accountAllowlist: Object.freeze([...routing.accountAllowlist]) }
                : {}),
        }),
    });
}
function assertResolvedRoutingValues(value, path = "routing") {
    if (typeof value === "string") {
        if (value.includes("${")) {
            throw new Error(`Unresolved environment placeholder at ${path}`);
        }
        return;
    }
    if (Array.isArray(value)) {
        value.forEach((entry, index) => assertResolvedRoutingValues(entry, `${path}[${index}]`));
        return;
    }
    if (value && typeof value === "object") {
        for (const [key, entry] of Object.entries(value)) {
            assertResolvedRoutingValues(entry, `${path}.${key}`);
        }
    }
}
async function buildCandidate(options, generation, allowMissingConfig, allowMissingEnvFile, rejectInvalidHotEnv) {
    const effectiveEnv = { ...options.baseEnv };
    let envFilePresent = false;
    if (options.envFilePath) {
        try {
            const envContent = await readFile(options.envFilePath, "utf8");
            const { parse } = await import("dotenv");
            Object.assign(effectiveEnv, parse(envContent));
            envFilePresent = true;
        }
        catch (error) {
            if (!isMissingFileError(error) ||
                options.envFileRequired ||
                !allowMissingEnvFile) {
                throw new Error(`Failed to load proxy env file ${options.envFilePath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
            }
        }
    }
    let loadedConfig = null;
    let configFilePresent = false;
    try {
        loadedConfig = (await loadProxyConfig(options.configPath, {
            env: effectiveEnv,
        }));
        configFilePresent = true;
    }
    catch (error) {
        if (!isMissingFileError(error) ||
            options.configRequired ||
            !allowMissingConfig) {
            throw error;
        }
    }
    const proxyConfig = cloneLoadedConfig(loadedConfig);
    const routing = proxyConfig?.routing;
    assertResolvedRoutingValues(routing);
    const strategy = normalizeStrategy(routing?.strategy, options.strategyOverride);
    const primaryAccountKey = await resolvePrimaryAccountKey(routing?.primaryAccount);
    const accountAllowlist = await resolveAccountAllowlist(routing?.accountAllowlist);
    if (primaryAccountKey &&
        !isAccountAllowed(primaryAccountKey, accountAllowlist)) {
        throw new Error(`Configured routing.primaryAccount=${routing?.primaryAccount} is excluded by routing.accountAllowlist`);
    }
    const quotaRoutingEnabled = resolveQuotaRoutingEnabled(effectiveEnv.NEUROLINK_PROXY_QUOTA_ROUTING, routing?.quotaRouting);
    const sessionSoftLimit = resolveSessionSoftLimit(effectiveEnv.NEUROLINK_PROXY_SESSION_SOFT_LIMIT, routing?.sessionSoftLimit, rejectInvalidHotEnv);
    const sessionResetToleranceMs = resolveSessionResetToleranceMs(effectiveEnv.NEUROLINK_PROXY_SESSION_RESET_TOLERANCE_MS, routing?.sessionResetToleranceMs, rejectInvalidHotEnv);
    const modelRouter = routing
        ? new ModelRouter({
            strategy,
            modelMappings: routing.modelMappings ?? [],
            fallbackChain: routing.fallbackChain ?? [],
            passthroughModels: routing.passthroughModels,
            quotaRouting: routing.quotaRouting,
            sessionSoftLimit: routing.sessionSoftLimit,
            sessionResetToleranceMs: routing.sessionResetToleranceMs,
            primaryAccount: routing.primaryAccount,
            accountAllowlist: routing.accountAllowlist,
        })
        : undefined;
    const fingerprintSource = JSON.stringify({
        strategy,
        passthrough: options.passthrough,
        routing: routing ?? null,
        primaryAccountKey,
        accountAllowlist: accountAllowlist ? [...accountAllowlist].sort() : null,
        quotaRoutingEnabled,
        sessionSoftLimit,
        sessionResetToleranceMs,
    });
    const configHash = createHash("sha256")
        .update(fingerprintSource)
        .digest("hex")
        .slice(0, 16);
    return {
        snapshot: Object.freeze({
            generation,
            loadedAt: new Date().toISOString(),
            configHash,
            proxyConfig,
            strategy,
            modelRouter,
            passthrough: options.passthrough,
            primaryAccountKey,
            accountAllowlist,
            quotaRoutingEnabled,
            sessionSoftLimit,
            sessionResetToleranceMs,
        }),
        configFilePresent,
        envFilePresent,
    };
}
/** Atomic last-known-good runtime configuration with file-triggered reloads. */
export class ProxyRuntimeConfigStore {
    options;
    currentSnapshot;
    status;
    listeners = new Set();
    reloadListeners = new Set();
    reloadQueue = Promise.resolve();
    reloadTimer;
    configFileObserved;
    envFileObserved;
    watchListener = (current, previous) => {
        if (current.mtimeMs === previous.mtimeMs &&
            current.size === previous.size &&
            current.nlink === previous.nlink) {
            return;
        }
        this.scheduleWatchReload();
    };
    constructor(options, snapshot, configFileObserved, envFileObserved) {
        this.options = { ...options, baseEnv: { ...options.baseEnv } };
        this.currentSnapshot = snapshot;
        this.configFileObserved = configFileObserved;
        this.envFileObserved = envFileObserved;
        this.status = {
            configPath: options.configPath,
            ...(options.envFilePath ? { envFilePath: options.envFilePath } : {}),
            generation: snapshot.generation,
            loadedAt: snapshot.loadedAt,
            configHash: snapshot.configHash,
            watching: false,
            consecutiveFailures: 0,
        };
    }
    static async create(options) {
        const candidate = await buildCandidate(options, 1, true, true, false);
        return new ProxyRuntimeConfigStore(options, candidate.snapshot, candidate.configFilePresent, candidate.envFilePresent);
    }
    getSnapshot() {
        return this.currentSnapshot;
    }
    getStatus() {
        return { ...this.status };
    }
    subscribe(listener) {
        this.listeners.add(listener);
        return () => this.listeners.delete(listener);
    }
    subscribeReload(listener) {
        this.reloadListeners.add(listener);
        return () => this.reloadListeners.delete(listener);
    }
    reload(source = "manual") {
        const run = this.reloadQueue.then(() => this.performReload(source), () => this.performReload(source));
        this.reloadQueue = run.then(() => undefined, () => undefined);
        return run;
    }
    startWatching() {
        if (this.status.watching) {
            return;
        }
        const interval = Math.max(50, this.options.watchIntervalMs ?? DEFAULT_WATCH_INTERVAL_MS);
        watchFile(this.options.configPath, { interval, persistent: false }, this.watchListener);
        if (this.options.envFilePath) {
            watchFile(this.options.envFilePath, { interval, persistent: false }, this.watchListener);
        }
        this.status = { ...this.status, watching: true };
    }
    stopWatching() {
        if (!this.status.watching) {
            return;
        }
        unwatchFile(this.options.configPath, this.watchListener);
        if (this.options.envFilePath) {
            unwatchFile(this.options.envFilePath, this.watchListener);
        }
        if (this.reloadTimer) {
            clearTimeout(this.reloadTimer);
            this.reloadTimer = undefined;
        }
        this.status = { ...this.status, watching: false };
    }
    scheduleWatchReload() {
        if (this.reloadTimer) {
            clearTimeout(this.reloadTimer);
        }
        this.reloadTimer = setTimeout(() => {
            this.reloadTimer = undefined;
            void this.reload("watch");
        }, Math.max(0, this.options.watchDebounceMs ?? DEFAULT_WATCH_DEBOUNCE_MS));
        this.reloadTimer.unref?.();
    }
    async performReload(source) {
        const attemptedAt = new Date().toISOString();
        this.status = {
            ...this.status,
            lastReloadAttemptAt: attemptedAt,
            lastReloadSource: source,
        };
        try {
            const candidate = await buildCandidate(this.options, this.currentSnapshot.generation + 1, !this.configFileObserved, !this.envFileObserved, true);
            this.configFileObserved ||= candidate.configFilePresent;
            this.envFileObserved ||= candidate.envFilePresent;
            if (candidate.snapshot.configHash === this.currentSnapshot.configHash) {
                this.status = {
                    ...this.status,
                    lastReloadAt: attemptedAt,
                    lastReloadError: undefined,
                    consecutiveFailures: 0,
                };
                const result = {
                    applied: true,
                    changed: false,
                    generation: this.currentSnapshot.generation,
                };
                this.notifyReloadListeners(result);
                return result;
            }
            this.currentSnapshot = candidate.snapshot;
            this.status = {
                ...this.status,
                generation: candidate.snapshot.generation,
                loadedAt: candidate.snapshot.loadedAt,
                configHash: candidate.snapshot.configHash,
                lastReloadAt: attemptedAt,
                lastReloadError: undefined,
                consecutiveFailures: 0,
            };
            logger.always(`[proxy] configuration generation ${candidate.snapshot.generation} applied via ${source}`);
            for (const listener of this.listeners) {
                try {
                    listener(candidate.snapshot);
                }
                catch (error) {
                    logger.warn(`[proxy] runtime config listener failed: ${error instanceof Error ? error.message : String(error)}`);
                }
            }
            const result = {
                applied: true,
                changed: true,
                generation: candidate.snapshot.generation,
            };
            this.notifyReloadListeners(result);
            return result;
        }
        catch (error) {
            const message = error instanceof Error ? error.message : String(error);
            this.status = {
                ...this.status,
                lastReloadError: message,
                consecutiveFailures: this.status.consecutiveFailures + 1,
            };
            logger.warn(`[proxy] configuration reload rejected; keeping generation ${this.currentSnapshot.generation}: ${message}`);
            const result = {
                applied: false,
                changed: false,
                generation: this.currentSnapshot.generation,
                error: message,
            };
            this.notifyReloadListeners(result);
            return result;
        }
    }
    notifyReloadListeners(result) {
        for (const listener of this.reloadListeners) {
            try {
                listener(result, this.getStatus());
            }
            catch (error) {
                logger.warn(`[proxy] runtime config reload listener failed: ${error instanceof Error ? error.message : String(error)}`);
            }
        }
    }
}
//# sourceMappingURL=runtimeConfig.js.map