/**
 * QuotaPoller — per-account live quota reader (P1.2 of the Subscription & Auth
 * Standard, decision C hybrid read).
 *
 * Produces an AccountQuotaSnapshot per SubscriptionPool account: 5-hour and
 * 7-day utilization + reset dates, per-model breakdown, and extra-usage credit
 * state — exactly what the QuotaAwareScheduler (P1.3) needs to drain each
 * account optimally before its reset and swap before a limit.
 *
 * ── Read mechanism (decision C, grounded by hands-on finding) ──
 * Justin chose C: drive Claude Code's own /usage surface by default, the
 * `GET /api/oauth/usage` endpoint as a bounded fallback. FINDING (2026-06-06):
 * Claude Code does NOT persist usage to disk and exposes no non-interactive
 * usage command, so a truly "read what the client cached" primary does not
 * exist. The only viable Claude mechanism is the OAuth usage endpoint — the
 * same endpoint the client's /usage screen calls internally. So the poller:
 *   - resolves each account's OAuth access token TRANSIENTLY from that account's
 *     own config-home credential store (never persisted, never logged),
 *   - calls the read-only usage endpoint at LOW frequency, and
 *   - stamps the snapshot `source: 'oauth-usage-endpoint-fallback'` for honesty.
 * This is read-only TELEMETRY, not inference — distinct from the inference-
 * spoofing Anthropic enforces against. It stays within decision C's accepted
 * bounds (subscription-only, no API keys, official-client login reused).
 *
 * ── Burn rate, not call count ──
 * The scheduler must decide on MEASURED utilization deltas over time, never raw
 * call volume (lesson: call counts overstate real burn ~100× because the
 * LlmQueue shed layer absorbs most background traffic). The poller exposes a
 * per-account burn rate (utilization %/hour) computed from consecutive reads.
 *
 * Testability: `fetchImpl` and `tokenResolver` are injectable so the whole
 * poller runs hermetically with zero credentials and zero network in tests.
 */
import fs from 'node:fs';
import path from 'node:path';
import { readClaudeOauth, refreshClaudeToken, expandHome, } from './OAuthRefresher.js';
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
/**
 * Resolve a claude-code account's OAuth access token from its config home,
 * TRANSIENTLY. Never persisted, never logged. Reads via the shared OAuthRefresher
 * locator (macOS keychain `Claude Code-credentials-<sha256(configHome)[0:8]>`,
 * else `<configHome>/.credentials.json`) so the resolver and the refresher always
 * agree on WHERE a config home's credentials live. NOTE: an EXPIRED access token
 * is still returned here (it's a valid string) — expiry is detected by the usage
 * read's 401 and recovered by the refresher, not by this resolver.
 */
export function defaultTokenResolver(account) {
    if (account.provider !== 'anthropic' || account.framework !== 'claude-code') {
        return null;
    }
    const oauth = readClaudeOauth(account.configHome);
    const tok = oauth?.accessToken;
    return typeof tok === 'string' && tok.startsWith('sk-ant-oat') ? tok : null;
}
/**
 * Read the account email (`oauthAccount.emailAddress`) Claude Code records for a
 * config home. This is a PUBLIC account identifier (not a secret) — it lets the
 * pool show WHICH account a slot actually authenticated as, so a login into the
 * wrong account surfaces instead of hiding. Tries `<configHome>/.claude.json`,
 * then (for the default home) the home-root `~/.claude.json`. Null if unreadable.
 */
export function readAccountEmail(configHome) {
    const home = expandHome(configHome);
    const candidates = [path.join(home, '.claude.json')];
    if (home === expandHome('~/.claude')) {
        candidates.push(path.join(process.env.HOME ?? '', '.claude.json'));
    }
    for (const f of candidates) {
        try {
            if (!fs.existsSync(f))
                continue;
            const j = JSON.parse(fs.readFileSync(f, 'utf-8'));
            const email = j?.oauthAccount?.emailAddress;
            if (typeof email === 'string' && email.includes('@'))
                return email;
        }
        catch {
            // @silent-fallback-ok: missing/unreadable config → no email (null)
        }
    }
    return null;
}
/**
 * Map the REAL /api/oauth/usage response (verified live 2026-06-06) into an
 * AccountQuotaSnapshot. The live shape is `five_hour: {utilization, resets_at}`,
 * `seven_day: {utilization, resets_at}`, `seven_day_sonnet`, `seven_day_opus`,
 * `extra_usage: {is_enabled, used_credits, monthly_limit}`.
 */
export function mapUsageResponse(body, source, nowIso) {
    const snap = { source, measuredAt: nowIso };
    const win = (v) => {
        if (!v || typeof v !== 'object')
            return undefined;
        const o = v;
        if (o.utilization === undefined && o.resets_at === undefined)
            return undefined;
        return {
            utilizationPct: Number(o.utilization ?? 0),
            resetsAt: String(o.resets_at ?? ''),
        };
    };
    const five = win(body['five_hour']);
    if (five)
        snap.fiveHour = five;
    const seven = win(body['seven_day']);
    if (seven)
        snap.sevenDay = seven;
    const perModel = {};
    for (const [key, label] of [
        ['seven_day_sonnet', 'sonnet'],
        ['seven_day_opus', 'opus'],
    ]) {
        const v = body[key];
        if (v && typeof v === 'object') {
            const u = v.utilization;
            perModel[label] = u === undefined || u === null ? null : Number(u);
        }
    }
    if (Object.keys(perModel).length > 0)
        snap.perModel = perModel;
    const extra = body['extra_usage'];
    if (extra && typeof extra === 'object') {
        const e = extra;
        snap.extraUsage = {
            isEnabled: Boolean(e.is_enabled),
            usedCredits: Number(e.used_credits ?? 0),
            monthlyLimit: Number(e.monthly_limit ?? 0),
        };
    }
    return snap;
}
export class QuotaPoller {
    pool;
    pollIntervalMs;
    fetchImpl;
    tokenResolver;
    refresher;
    logger;
    locationGate;
    interval = null;
    /** Most-recent snapshot per account id. */
    lastByAccount = new Map();
    /** The snapshot BEFORE the most recent, per account — the burn-rate baseline. */
    prevByAccount = new Map();
    constructor(config) {
        this.pool = config.pool;
        this.pollIntervalMs = config.pollIntervalMs ?? 15 * 60_000;
        this.fetchImpl =
            config.fetchImpl ??
                ((url, init) => fetch(url, init));
        this.tokenResolver = config.tokenResolver ?? defaultTokenResolver;
        this.refresher =
            config.refresher ?? ((account) => refreshClaudeToken(expandHome(account.configHome)));
        this.logger = config.logger ?? { log: () => { }, warn: () => { } };
        this.locationGate = config.locationGate;
    }
    /**
     * Census re-routing (§2.2 rows #1–#4): resolve the account's LIVE slot home through the ledger
     * gate when enabled, else its enrollment `configHome` (today's behavior). The result is the
     * config home every per-account credential read in this poller targets — so a swap mid-poll
     * reads/refreshes/attributes against the slot the credential ACTUALLY lives in now. Sync,
     * fail-open-loud (the gate never throws), back-compat when the ledger is unknown/never-seeded.
     *
     * Returns the account UNCHANGED when no re-route is needed, so the byte-identical flag-off path
     * does not allocate a clone (and the default token resolver / refresher see the exact same
     * object they do today).
     */
    accountForReads(account) {
        if (!this.locationGate)
            return account;
        const slot = this.locationGate.slotForAccount(account.id, account.configHome);
        if (slot === account.configHome)
            return account;
        return { ...account, configHome: slot };
    }
    start() {
        if (this.interval)
            return;
        this.interval = setInterval(() => {
            void this.pollAll();
        }, this.pollIntervalMs);
        this.interval.unref?.();
    }
    stop() {
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }
    }
    /**
     * One usage read. Returns the parsed body (or null body on a non-auth non-ok),
     * an auth-failed marker (401/403), or null on a network failure. NEVER logs the
     * token.
     */
    async readUsage(token) {
        try {
            const res = await this.fetchImpl(USAGE_URL, {
                headers: {
                    Authorization: `Bearer ${token}`,
                    'anthropic-beta': 'oauth-2025-04-20',
                    'anthropic-version': '2023-06-01',
                },
            });
            if (res.ok) {
                return { authFailed: false, body: (await res.json()) };
            }
            if (res.status === 401 || res.status === 403)
                return { authFailed: true };
            return { authFailed: false, body: null }; // 5xx etc. → no snapshot, not auth
        }
        catch {
            // @silent-fallback-ok: network failure → no snapshot this cycle (retry next)
            return null;
        }
    }
    markNeedsReauth(account, reason) {
        try {
            this.pool.update(account.id, { status: 'needs-reauth' });
        }
        catch {
            // @silent-fallback-ok: pool update best-effort; status reflects next read
        }
        this.logger.warn(`[QuotaPoller] account ${account.id} → needs-reauth (${reason})`);
    }
    /**
     * Poll one account: resolve token transiently, read usage, map to snapshot.
     * On a usage-read auth failure (401/403) the access token may simply have
     * EXPIRED while the refresh token is still valid — so the poller attempts a
     * refresh-token exchange and ONE retry BEFORE declaring needs-reauth. Only a
     * genuinely dead login (no refresh token / refresh rejected / still 401 after
     * a fresh token) yields needs-reauth. Returns null when the token is
     * unresolvable or the read fails. NEVER logs or returns the token.
     */
    async pollAccount(account) {
        // Census #1/#2/#4: every per-account credential read (token resolve, 401-refresh, needs-reauth
        // attribution) targets the account's LIVE slot per the ledger gate — NOT its enrollment home —
        // so a swap mid-poll can't read/refresh/flag the wrong tenant. account.id is preserved (only
        // the slot home moves), so pool.update + logging still name the right account.
        const slotAccount = this.accountForReads(account);
        const token = this.tokenResolver(slotAccount);
        if (!token) {
            this.logger.warn(`[QuotaPoller] no resolvable token for account ${account.id} — skipping`);
            return null;
        }
        const read = await this.readUsage(token);
        if (read === null)
            return null; // network failure
        let body;
        if (read.authFailed) {
            const refreshed = await this.refresher(slotAccount);
            if (!refreshed.ok) {
                if (refreshed.reason === 'write-skipped') {
                    // The refresh exchange SUCCEEDED but the per-slot credential funnel lock was busy
                    // (a swap or a concurrent refresh holds it). Transient — no snapshot this cycle,
                    // retry next tick. NEVER needs-reauth: the login is fully intact (Step 4b).
                    this.logger.warn(`[QuotaPoller] account ${account.id} refresh-write skipped (slot busy) — no snapshot this cycle`);
                    return null;
                }
                // No refresh token, or the exchange was rejected — genuine re-auth.
                this.markNeedsReauth(account, refreshed.reason);
                return null;
            }
            const retry = await this.readUsage(refreshed.accessToken);
            if (retry === null)
                return null; // network blip on the retry → next cycle
            if (retry.authFailed) {
                // Fresh token still rejected — treat as genuinely failed.
                this.markNeedsReauth(account, 'usage still auth-failed after refresh');
                return null;
            }
            // Recovered silently — no operator action needed. Record for visibility.
            try {
                this.pool.update(account.id, { lastRefreshAt: new Date().toISOString() });
            }
            catch {
                // @silent-fallback-ok: visibility-only write
            }
            this.logger.log(`[QuotaPoller] account ${account.id} access token refreshed silently (no re-auth needed)`);
            body = retry.body;
        }
        else {
            body = read.body;
        }
        if (!body)
            return null;
        const snap = mapUsageResponse(body, 'oauth-usage-endpoint-fallback', new Date().toISOString());
        // Shift the prior "last" down to "prev" so burnRate has a distinct baseline.
        const priorLast = this.lastByAccount.get(account.id);
        if (priorLast)
            this.prevByAccount.set(account.id, priorLast);
        this.lastByAccount.set(account.id, snap);
        return snap;
    }
    /**
     * Poll every claude-code/anthropic account in the pool and persist each
     * account's latest snapshot (and a recovered status when a prior needs-reauth
     * account now reads cleanly).
     */
    async pollAll() {
        let polled = 0;
        let failed = 0;
        for (const account of this.pool.list()) {
            if (account.provider !== 'anthropic' || account.framework !== 'claude-code')
                continue;
            if (account.status === 'disabled')
                continue;
            const snap = await this.pollAccount(account);
            if (!snap) {
                failed++;
                continue;
            }
            polled++;
            const patch = { lastQuota: snap };
            // A clean read on an account previously flagged needs-reauth restores it.
            if (account.status === 'needs-reauth')
                patch.status = 'active';
            // Census #3: email auto-patch. When credential re-pointing is enabled the enrollment home
            // no longer maps 1:1 to a tenant (a swap moves the credential), so reading the slot's
            // `.claude.json` email and writing it onto this pool account would CROSS-CONTAMINATE pool
            // emails and poison the recovery probe's email→account map. So while the gate is enabled the
            // auto-patch is SUPPRESSED (the ledger + identity oracle own divergence detection now). With
            // the gate absent/off this is byte-for-byte today's behavior.
            if (!this.locationGate?.isEnabled()) {
                // Auto-populate the account email from the config home's own login record,
                // so the stored email always reflects which account actually authenticated
                // (a login into the wrong account surfaces here instead of hiding).
                const email = readAccountEmail(account.configHome);
                if (email && email !== account.email)
                    patch.email = email;
            }
            try {
                this.pool.update(account.id, patch);
            }
            catch {
                // @silent-fallback-ok: persistence best-effort; snapshot retained in memory
            }
        }
        return { polled, failed };
    }
    /**
     * Burn rate for an account, computed from the two most recent reads. Returns
     * null until at least two distinct reads exist. Uses MEASURED utilization
     * deltas — never call counts. The caller (P1.3 scheduler) decides on these.
     */
    burnRate(accountId) {
        const prev = this.prevByAccount.get(accountId);
        const current = this.lastByAccount.get(accountId);
        if (!prev || !current || prev.measuredAt === current.measuredAt)
            return null;
        const t0 = Date.parse(prev.measuredAt ?? '');
        const t1 = Date.parse(current.measuredAt ?? '');
        if (!Number.isFinite(t0) || !Number.isFinite(t1) || t1 <= t0)
            return null;
        const spanMs = t1 - t0;
        const hours = spanMs / 3_600_000;
        const delta = (a, b) => a && b ? (b.utilizationPct - a.utilizationPct) / hours : null;
        return {
            sevenDayPctPerHour: delta(prev.sevenDay, current.sevenDay),
            fiveHourPctPerHour: delta(prev.fiveHour, current.fiveHour),
            spanMs,
        };
    }
    /** Expose the last in-memory snapshot for an account (test/diagnostic). */
    lastSnapshot(accountId) {
        return this.lastByAccount.get(accountId) ?? null;
    }
}
//# sourceMappingURL=QuotaPoller.js.map