/**
 * Live auth-health: does an agent account's stored credential actually complete
 * an authenticated request right now?
 *
 * The rest of the CLI reports "signed in" from a local heuristic — a credential
 * file is present and its email decodes — which cannot distinguish a good token
 * from a revoked-but-unexpired one. This module completes a real request per
 * (agent, account) and records the verdict in a small cache that `agents view`
 * (per-version chip), `agents fleet status` (the per-host Auth column, via
 * {@link summarizeHostAuth}), and the run rotation all read. The writers are
 * the daemon (a periodic local refresh) and `agents fleet ping` (which also
 * fans out to write remote hosts' rows into the local cache); everyone else
 * reads.
 *
 * The network probes themselves live in lib/usage.ts (where the per-provider
 * token loaders + endpoints already are); this module classifies their result,
 * covers the best-effort (non-networked) providers, and owns the cache.
 */
import * as fs from 'fs';
import * as path from 'path';
import { ALL_AGENT_IDS, getAccountInfo } from './agents.js';
import { getCacheDir } from './state.js';
import { probeClaudeStatus, probeDroidStatus, probeKimiStatus, USAGE_HEADLESS_SCOPE_MARKER, readClaudeUsageCache, } from './accounting/usage.js';
import { getVersionHomePath, listInstalledVersions } from './installations/versions.js';
import { atomicWriteFileSync, ensureLockTarget, withFileLock } from './fs-atomic.js';
/** Maximum age of an auth verdict used for automatic routing decisions. */
export const AUTH_PROBE_MAX_AGE_MS = 20 * 60_000;
/** Agents with a live network probe wired up today. The rest are best-effort. */
export const LIVE_PROBE_AGENTS = new Set(['claude', 'kimi', 'droid']);
// ---------------------------------------------------------------------------
// Pure classifiers / render (unit-tested; no network, no fs)
// ---------------------------------------------------------------------------
/** Map an HTTP status from a live probe to a verdict. */
export function classifyHttpStatus(status) {
    if (status >= 200 && status < 300)
        return 'live';
    if (status === 401 || status === 403)
        return 'revoked';
    if (status === 429)
        return 'rate_limited';
    return 'error';
}
/** Turn a raw provider probe (from usage.ts) into a verdict. */
export function verdictFromProbe(probe) {
    if (probe.token === 'missing')
        return 'unconfigured';
    if (probe.token === 'expired')
        return 'expired';
    // Setup-token can run inference but cannot read usage (RUSH-2392). That 403
    // is NOT a revocation — classifying it as revoked made the best-provisioned
    // headless accounts look the least healthy on `agents view` / fleet ping.
    if (probe.reason === 'usage_scope')
        return 'unverified';
    if (probe.status == null)
        return 'error';
    return classifyHttpStatus(probe.status);
}
/** A short human detail line for a probe result (rendered under --verbose). */
export function probeDetail(probe) {
    if (probe.reason === 'usage_scope') {
        return probe.error ?? USAGE_HEADLESS_SCOPE_MARKER;
    }
    if (probe.status != null && (probe.status < 200 || probe.status >= 300))
        return `HTTP ${probe.status}`;
    if (probe.error)
        return probe.error;
    return undefined;
}
const VERDICT_GLYPHS = {
    live: '●', // ●
    revoked: '○', // ○
    expired: '○', // ○
    rate_limited: '◐', // ◐
    unverified: '◐', // ◐
    unconfigured: '·', // ·
    error: '·', // ·
};
/** Uncolored glyph for a verdict (color is applied by the caller). */
export function verdictGlyph(verdict) {
    return VERDICT_GLYPHS[verdict] ?? '·';
}
/** One-word label for matrices/verbose output. */
export function verdictLabel(verdict) {
    switch (verdict) {
        case 'live': return 'live';
        case 'revoked': return 'revoked';
        case 'expired': return 'expired';
        case 'rate_limited': return 'limited';
        case 'unverified': return 'unverified';
        case 'unconfigured': return '—';
        case 'error': return '?';
    }
}
export function summarizeVerdicts(verdicts) {
    let live = 0;
    let present = 0;
    let bad = 0;
    let warn = 0;
    for (const v of verdicts) {
        if (v === 'live')
            live++;
        else if (v === 'unverified')
            present++;
        else if (v === 'revoked')
            bad++;
        else
            warn++;
    }
    return { live, present, bad, warn, total: verdicts.length };
}
/**
 * Color for a single verdict in the per-account (`--verbose`) breakdown. This is
 * the one source of truth shared with {@link authCellColor} so the matrix and the
 * account list can never drift — they did: the matrix painted `expired` yellow
 * while the verbose list painted it *red* (lumped with revoked), directly against
 * the {@link VerdictSummary} contract. Red is reserved for `revoked` (the only
 * "re-login now"); `unverified` is a neutral signed-in state (gray);
 * `expired`/`rate_limited`/`error` are soft (yellow).
 */
export function verdictColor(verdict) {
    switch (verdict) {
        case 'live': return 'green';
        case 'revoked': return 'red';
        case 'unverified': return 'gray';
        case 'unconfigured': return 'dim';
        default: return 'yellow'; // expired / rate_limited / error — soft, self-healing/indeterminate
    }
}
/**
 * Color for a matrix cell that rolls up several accounts. Red only when a token
 * was genuinely rejected (`revoked`); yellow for soft/expired; green when at least
 * one account is live-verified and none are soft/revoked; gray when accounts are
 * present but unverifiable (codex/grok) — never the alarming yellow the old
 * renderer used, which made a fully-logged-in fleet read as half-broken.
 */
export function authCellColor(summary) {
    if (summary.total === 0)
        return 'dim';
    if (summary.bad > 0)
        return 'red';
    if (summary.warn > 0)
        return 'yellow';
    if (summary.live > 0)
        return 'green';
    return 'gray'; // all present/unverifiable — signed in, neutral
}
/** Verdicts that mean "this token was rejected by the server — re-login required". */
export function isDeadVerdict(verdict) {
    return verdict === 'revoked';
}
/**
 * Roll every cached (agent, version) row for one host into a {@link HostAuthSummary}
 * plus the age of its stalest entry. Pure — reads the map the caller already
 * loaded via {@link readAuthHealthCache}, so `fleet status` renders the Auth
 * column without any network probe. A host with no cached rows yields an empty
 * summary (total 0), which the renderer shows as "—".
 *
 * Keys are `host:agent:version` ({@link authCacheKey}); we match on the `host:`
 * prefix so agent/version segments can never be mistaken for a host.
 */
export function summarizeHostAuth(cache, host) {
    const prefix = `${host}:`;
    let live = 0, present = 0, degraded = 0, revoked = 0, total = 0;
    let oldest = null;
    for (const [key, health] of Object.entries(cache)) {
        if (!key.startsWith(prefix))
            continue;
        // `unconfigured` = no credential at all — not a probed account. Writers
        // already drop these before they reach the cache; skip here too so a stray
        // one never counts toward total or the freshness age (belt-and-suspenders).
        if (health.verdict === 'unconfigured')
            continue;
        total++;
        switch (health.verdict) {
            case 'live':
                live++;
                break;
            case 'unverified':
                present++;
                break; // signed in, no probe — benign
            case 'revoked':
                revoked++;
                break; // server said no — re-login
            default:
                degraded++;
                break; // expired / rate_limited / error — soft
        }
        if (oldest === null || health.checkedAt < oldest)
            oldest = health.checkedAt;
    }
    return { live, present, degraded, revoked, total, oldestCheckedAt: oldest };
}
/** Human "3m ago" style age for a checkedAt timestamp. */
export function formatCheckedAge(checkedAt, now = Date.now()) {
    const secs = Math.max(0, Math.round((now - checkedAt) / 1000));
    if (secs < 60)
        return `${secs}s ago`;
    const mins = Math.round(secs / 60);
    if (mins < 60)
        return `${mins}m ago`;
    const hours = Math.round(mins / 60);
    if (hours < 24)
        return `${hours}h ago`;
    return `${Math.round(hours / 24)}d ago`;
}
// ---------------------------------------------------------------------------
// Cache identity + IO (single source of truth read by view/fleet/rotation)
// ---------------------------------------------------------------------------
/**
 * Human account label for display (email, else id). NOT used in the cache key —
 * two installs on one host can hold the same account with independently valid
 * tokens, so the key is keyed by version (below), not account.
 */
export function authAccountLabel(info) {
    return info?.email || info?.accountId || info?.userId || undefined;
}
/** Cache key: one entry per install — (host, agent, version). Unique per token. */
export function authCacheKey(host, agent, version) {
    return `${host}:${agent}:${version}`;
}
function cacheFilePath() {
    return path.join(getCacheDir(), '.auth-health.json');
}
/** Read the whole cache (best-effort; a corrupt/missing file yields an empty map). */
export function readAuthHealthCache() {
    try {
        const parsed = JSON.parse(fs.readFileSync(cacheFilePath(), 'utf-8'));
        if (parsed && parsed.entries && typeof parsed.entries === 'object')
            return parsed.entries;
    }
    catch {
        // missing or corrupt — treat as empty
    }
    return {};
}
/** Read one entry, or null. */
export function readAuthHealth(host, agent, version) {
    return readAuthHealthCache()[authCacheKey(host, agent, version)] ?? null;
}
/** Reconstruct one host's published probe rows for a lease waiter/CLI reader. */
export function readFleetAuthRows(host) {
    const prefix = `${host}:`;
    const rows = [];
    for (const [key, health] of Object.entries(readAuthHealthCache())) {
        if (!key.startsWith(prefix))
            continue;
        const identity = key.slice(prefix.length);
        const separator = identity.indexOf(':');
        if (separator <= 0)
            continue;
        const agent = identity.slice(0, separator);
        if (!ALL_AGENT_IDS.includes(agent))
            continue;
        rows.push({
            agent: agent,
            version: identity.slice(separator + 1),
            account: health.account,
            health,
        });
    }
    return rows;
}
/**
 * Merge entries into the cache. An incoming `error` verdict (a network blip,
 * not a server rejection) is indeterminate, so it must NOT clobber a prior
 * known verdict — otherwise one 8s timeout flips a `live` chip to `error`,
 * exactly the "cry wolf" the verdict model avoids for `expired`. This is the
 * behaviour promised by the `error` doc on AuthVerdict ("keep the last known
 * one"). Pure, so it's unit-tested directly. */
export function mergeAuthHealthEntries(current, incoming) {
    const merged = { ...current };
    for (const [key, health] of Object.entries(incoming)) {
        if (health.verdict === 'error' && merged[key])
            continue; // keep last known
        merged[key] = health;
    }
    return merged;
}
/** Merge one or more entries into the cache (best-effort write). */
export function writeAuthHealthEntries(entries) {
    try {
        const target = cacheFilePath();
        ensureLockTarget(target, JSON.stringify({ version: 1, entries: {} }));
        withFileLock(target, () => {
            const merged = {
                version: 1,
                entries: mergeAuthHealthEntries(readAuthHealthCache(), entries),
            };
            atomicWriteFileSync(target, JSON.stringify(merged, null, 2));
        });
    }
    catch {
        // best-effort; a failed write just means the next reader falls back to heuristics
    }
}
// ---------------------------------------------------------------------------
// The probe (writer side)
// ---------------------------------------------------------------------------
/**
 * A usage snapshot this recent is live proof the account's shared setup-token
 * works: the snapshot only exists because an authenticated `/oauth/usage`
 * request succeeded. Matches the periodic tick's own probe window
 * (AUTH_PROBE_MAX_AGE_MS in daemon-ticks.ts — not imported to avoid a cycle;
 * a drift here only widens/narrows evidence freshness, never correctness).
 */
const FRESH_USAGE_VERDICT_MAX_AGE_MS = 20 * 60_000;
/**
 * A `live` verdict derived from the account's usage cache instead of a second
 * network request (RUSH-3036). The auth probe and the usage fetch hit the SAME
 * rate-limited endpoint with the SAME fleet-shared setup-token, so a fresh
 * successful usage snapshot already proves everything the probe would: paying a
 * second request per account per box was half the fleet's endpoint load.
 *
 * Two guards keep the evidence honest (both review findings on the first cut):
 * the caller must assert this box holds a LOCAL credential for the account
 * (`signedIn`) — a fleet-imported snapshot proves the shared token works, not
 * that THIS box can authenticate, so an unsigned home never derives `live`;
 * and a `forceLive` caller (`agents devices ping --strict`) skips derivation
 * entirely, because its contract is a real request that can surface `revoked`
 * within seconds, not minutes. Returns null when there is no admissible fresh
 * evidence — the caller then live-probes as before.
 */
function verdictFromFreshUsage(usageKey, signedIn, now) {
    if (!usageKey || !signedIn)
        return null;
    const snapshot = readClaudeUsageCache(usageKey);
    const capturedAt = snapshot?.capturedAt?.getTime();
    if (!capturedAt || now - capturedAt >= FRESH_USAGE_VERDICT_MAX_AGE_MS)
        return null;
    const ageMin = Math.max(1, Math.round((now - capturedAt) / 60_000));
    return { verdict: 'live', checkedAt: now, detail: `token proven live by a usage fetch ${ageMin}m ago` };
}
/**
 * Complete a live auth probe for one (agent, home). For claude/kimi/droid this
 * hits the provider — unless the account's usage cache already holds a fresh
 * successful fetch, which is the same authenticated request and proves the
 * token live without spending a second one (RUSH-3036). For everyone else it
 * reports a best-effort local verdict (`unverified` when a credential is
 * present, `unconfigured` otherwise) — never masquerading as `live`.
 */
export async function probeAuthHealth(agent, home, opts) {
    const checkedAt = Date.now();
    if (LIVE_PROBE_AGENTS.has(agent)) {
        const usageScope = opts?.info?.usageKey ?? null;
        if (opts?.forceLive !== true) {
            const derived = verdictFromFreshUsage(usageScope, opts?.info?.signedIn === true, checkedAt);
            if (derived)
                return derived;
        }
        let probe;
        if (agent === 'claude')
            probe = await probeClaudeStatus(home, opts?.cliVersion, usageScope, opts?.signal);
        else if (agent === 'kimi')
            probe = await probeKimiStatus(home, usageScope, opts?.signal);
        else
            probe = await probeDroidStatus(home, usageScope, opts?.signal);
        return { verdict: verdictFromProbe(probe), checkedAt, detail: probeDetail(probe) };
    }
    const info = opts?.info !== undefined ? opts.info : await getAccountInfo(agent, home).catch(() => null);
    return { verdict: info?.signedIn ? 'unverified' : 'unconfigured', checkedAt };
}
/**
 * Collapse installs so a live auth probe fires ONCE per (agent, account) rather
 * than once per version home.
 *
 * Several version homes signed into the same provider account share one OAuth
 * rate limit, so probing each of them concurrently — which the daemon did every
 * three minutes across every installed home (RUSH-2111) — raced that limit into
 * a 429 storm that then parked the whole box behind a `Retry-After` penalty (the
 * incident {@link file:./usage-backoff.ts} was written to survive; this removes
 * its cause). Grouping by account means N homes on one account issue ONE request.
 *
 * `isMergeable` scopes the dedup to the installs it actually helps: only agents
 * that make a network probe ({@link LIVE_PROBE_AGENTS}) can 429, so only they are
 * merged. A best-effort agent (its verdict is a cheap local file read, no rate
 * limit) is left per-install — collapsing it would gain nothing and could
 * silently override one home's local `signedIn` verdict with another's. Installs
 * with no resolvable account label, or that `isMergeable` rejects, are never
 * merged: each becomes its own group keyed by version, preserving the old
 * per-install probe (also the `unconfigured` case dropped downstream). Pure: no
 * fs, no network, so the dedup decision is unit-tested directly.
 */
export function groupFleetAuthInstalls(installs, isMergeable = () => true) {
    const groups = new Map();
    for (const inst of installs) {
        // The `acct:` / `ver:` tokens make the two branches disjoint, so an account
        // label can never collide with a version fallback key no matter its content.
        const key = inst.account && isMergeable(inst)
            ? `${inst.agent} acct:${inst.account}`
            : `${inst.agent} ver:${inst.version}`;
        const existing = groups.get(key);
        if (existing)
            existing.members.push(inst);
        else
            groups.set(key, { probe: inst, members: [inst] });
    }
    return [...groups.values()];
}
/**
 * Enumerate every installed (agent, version) on THIS host and return one row per
 * install (installs with no credential at all are dropped). Shared by
 * `agents fleet ping --local` and the daemon refresh.
 *
 * The live network probe is deduped by account: homes sharing one provider
 * account are probed ONCE and the verdict is fanned out to each home's row, so
 * the daemon's every-3-minute refresh can no longer fire concurrent same-account
 * requests and self-inflict a 429 (RUSH-2111). Each home still gets its own cache
 * row (the cache key is per-version by design — see {@link authCacheKey}).
 */
export async function probeLocalFleetAuth(opts) {
    const agentIds = opts?.agents ?? ALL_AGENT_IDS;
    // Enumerate every install, then resolve its account label. getAccountInfo is a
    // local credential-file read (no network), so this fan-out is cheap and cannot
    // contribute to the rate limit the probe grouping below exists to avoid.
    const installs = [];
    for (const agent of agentIds) {
        for (const version of listInstalledVersions(agent)) {
            installs.push({ agent, version, home: getVersionHomePath(agent, version), info: null, account: undefined });
        }
    }
    await Promise.all(installs.map(async (inst) => {
        inst.info = await getAccountInfo(inst.agent, inst.home).catch(() => null);
        inst.account = authAccountLabel(inst.info);
    }));
    // Probe once per (agent, account) — but only for the network-probing agents
    // that can actually 429; best-effort agents stay per-install (see
    // groupFleetAuthInstalls). Groups run in parallel: they target distinct
    // accounts, so no same-account concurrency is left to trip the throttle.
    const perGroup = await Promise.all(groupFleetAuthInstalls(installs, (inst) => LIVE_PROBE_AGENTS.has(inst.agent)).map(async (group) => {
        const rep = group.probe;
        const health = await probeAuthHealth(rep.agent, rep.home, { cliVersion: opts?.cliVersion, info: rep.info, forceLive: opts?.forceLive, signal: opts?.signal });
        health.account = authAccountLabel(rep.info);
        if (health.verdict === 'unconfigured')
            return [];
        return group.members.map((inst) => ({
            agent: inst.agent,
            version: inst.version,
            account: health.account,
            // A distinct object per row so a later mutation of one can't bleed across.
            health: { ...health },
        }));
    }));
    return perGroup.flat();
}
/** Persist a host's probed rows into the cache (keyed by host+agent+version). */
export function writeFleetAuthRows(host, rows) {
    const entries = {};
    for (const row of rows) {
        entries[authCacheKey(host, row.agent, row.version)] = row.health;
    }
    writeAuthHealthEntries(entries);
}
