/**
 * Host readiness + bootstrap.
 *
 * Before a dispatch we ensure the box is reachable and has agents-cli. Enrollment
 * can additionally install/upgrade agents-cli to match the local version (version
 * parity) using the same shape as scripts/sandbox.sh. We never copy `.history`.
 */
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { sshExec, shellQuote } from '../ssh-exec.js';
import { hostIdentityArgs, sshTargetFor } from './types.js';
import { remoteShellFor, buildWindowsAgentsCommand, encodePowershell, powershellQuote, POWERSHELL_PROGRESS_SILENCE } from './remote-cmd.js';
import { resolveRemoteOsSync } from './remote-os.js';
import { AUTH_PROBE_MAX_AGE_MS, isDeadVerdict } from '../auth-health.js';
import { USAGE_STALE_REFUSAL_MAX_AGE_MS } from '../accounting/rotate.js';
/** Resolve this CLI's own version by walking up to the nearest package.json. */
export function localCliVersion() {
    let dir = path.dirname(fileURLToPath(import.meta.url));
    for (let i = 0; i < 8; i++) {
        const pkg = path.join(dir, 'package.json');
        try {
            const data = JSON.parse(fs.readFileSync(pkg, 'utf-8'));
            if (data.name && data.version)
                return data.version;
        }
        catch {
            /* keep walking */
        }
        const parent = path.dirname(dir);
        if (parent === dir)
            break;
        dir = parent;
    }
    return null;
}
/** ssh command that confirms reachability and echoes the OS. POSIX boxes answer
 * `uname -s`; a Windows target has no `uname` (ssh lands in cmd.exe/PowerShell),
 * so it runs a tiny PowerShell probe. Pure/exported so both branches are
 * unit-testable without ssh. */
export function buildProbeCommand(os) {
    if (remoteShellFor(os) === 'powershell') {
        return `powershell -NoProfile -EncodedCommand ${encodePowershell('[System.Environment]::OSVersion.Platform.ToString()')}`;
    }
    return 'uname -s 2>/dev/null || echo unknown';
}
/**
 * Reachability + OS probe over ssh. `os` is the caller's hint (device-registry
 * platform / enrolled `HostEntry.os`); when it marks the host Windows we take
 * the PowerShell path and report that known platform, otherwise POSIX + uname.
 */
export function probeHost(target, os, extraSshArgs = []) {
    const r = sshExec(target, buildProbeCommand(os), { timeoutMs: 12000, extraSshArgs });
    if (r.code !== 0)
        return { reachable: false };
    if (remoteShellFor(os) === 'powershell')
        return { reachable: true, os };
    const uname = r.stdout.trim();
    return { reachable: true, os: uname && uname !== 'unknown' ? uname : undefined };
}
/** ssh command that prints the remote agents-cli version. Pure/exported. */
export function buildRemoteVersionCommand(os) {
    return remoteShellFor(os) === 'powershell'
        ? buildWindowsAgentsCommand({ args: ['--version'] })
        : 'bash -lc "agents --version 2>/dev/null"';
}
/** Remote agents-cli version (PATH-resolved on the remote), or null if not installed. */
export function remoteAgentsVersion(target, os, extraSshArgs = []) {
    const r = sshExec(target, buildRemoteVersionCommand(os), { timeoutMs: 20000, extraSshArgs });
    if (r.code !== 0)
        return null;
    const v = r.stdout.trim();
    return v || null;
}
/** ssh command that installs/upgrades agents-cli to `spec` then `agents setup`.
 * PowerShell has no `tail`/`[ -d ]`/`||`, so the Windows branch uses native
 * equivalents (`Select-Object -Last`, `Test-Path`). Pure/exported. */
export function buildBootstrapCommand(spec, os) {
    if (remoteShellFor(os) === 'powershell') {
        const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
            `npm install -g ${powershellQuote(spec)} 2>&1 | Select-Object -Last 3; ` +
            `if (-not (Test-Path "$HOME/.agents/.system")) { agents setup 2>&1 | Select-Object -Last 3 }; ` +
            `agents --version`;
        return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
    }
    const script = `npm install -g ${shellQuote(spec)} 2>&1 | tail -3; ` +
        `if [ ! -d ~/.agents/.system ]; then agents setup 2>&1 | tail -3 || true; fi; ` +
        `agents --version`;
    return `bash -lc ${shellQuote(script)}`;
}
/** Install (or upgrade to) a specific agents-cli version on the remote, then `agents setup`. */
export function bootstrapAgentsCli(target, version, os, extraSshArgs) {
    const spec = version ? `@phnx-labs/agents-cli@${version}` : '@phnx-labs/agents-cli';
    const r = sshExec(target, buildBootstrapCommand(spec, os), { timeoutMs: 300000, extraSshArgs });
    return { ok: r.code === 0, output: (r.stdout + r.stderr).trim() };
}
/** Sentinel splitting the version output from the agent listing in one probe. */
const READY_MARKER = '@@AGENTS_READY@@';
/**
 * Answer every readiness question in ONE ssh round-trip: reachable? (the login
 * shell ran and echoed our sentinel), agents-cli version, and the installed-agent
 * listing. This replaces three sequential probes (`true` + `agents --version` +
 * `agents view`) — 3 handshakes collapse to 1. Reachability keys off the sentinel
 * rather than the exit code, so a command that ran-but-failed is never mistaken
 * for a dead connection (only ssh's own failure drops the sentinel).
 */
/**
 * The one-shot readiness command (version + sentinel + agent listing). The
 * Windows branch emits the sentinel with `Write-Output` and branches on
 * `$LASTEXITCODE` (no `printf`/`||`); `parseReadyProbe` keys off the sentinel
 * substring, so the missing leading newline vs the POSIX `printf '\n…'` form is
 * absorbed by its `.trim()`. Pure/exported so both branches are unit-testable.
 */
export function buildReadyProbeCommand(os) {
    if (remoteShellFor(os) === 'powershell') {
        const script = `${POWERSHELL_PROGRESS_SILENCE}; ` +
            `agents --version 2>$null; Write-Output "${READY_MARKER}"; ` +
            `agents view --json 2>$null; if ($LASTEXITCODE -ne 0) { agents list 2>$null }`;
        return `powershell -NoProfile -EncodedCommand ${encodePowershell(script)}`;
    }
    const script = `agents --version 2>/dev/null; printf '\\n${READY_MARKER}\\n'; ` +
        `agents view --json 2>/dev/null || agents list 2>/dev/null`;
    return `bash -lc ${shellQuote(script)}`;
}
export function readyProbe(target, os, extraSshArgs) {
    // Disable multiplexing: a stale control socket can hang the local ssh client
    // until the timeout fires, just like sshExecAsync does for the same reason.
    const r = sshExec(target, buildReadyProbeCommand(os), { timeoutMs: 20000, multiplex: false, extraSshArgs });
    if (r.timedOut)
        return { reachable: false, version: null, view: '', timedOut: true };
    return parseReadyProbe(r.stdout);
}
/** Pure parser for `readyProbe` output (unit-tested without ssh). */
export function parseReadyProbe(stdout) {
    const idx = stdout.indexOf(READY_MARKER);
    if (idx === -1)
        return { reachable: false, version: null, view: '' };
    const version = stdout.slice(0, idx).trim().replace(/^v/, '') || null;
    return { reachable: true, version, view: stdout.slice(idx + READY_MARKER.length) };
}
/** True if `view` output lists the named agent (word-boundary, case-insensitive). */
export function viewHasAgent(view, agent) {
    return new RegExp(`\\b${agent}\\b`, 'i').test(view);
}
/**
 * True when `version` is a concrete pin the remote must already have installed
 * (e.g. `0.145.0`, `2.1.207`). Aliases (`latest` / `oldest` / `pinned` /
 * `default` / `all` / `any`) resolve against the remote's own install set and
 * are not preflight-checked here.
 */
export function isConcreteVersionPin(version) {
    if (!version)
        return false;
    const v = version.trim();
    if (!v)
        return false;
    // Keep in lockstep with agent-spec AGENT_QUALIFIERS (+ `any` filter token).
    if (['latest', 'oldest', 'pinned', 'default', 'all', 'any'].includes(v.toLowerCase()))
        return false;
    // Same shape agent-spec accepts for an exact pin (no path traversal / spaces).
    return /^(?!.*\.\.)[A-Za-z0-9._+-]{1,64}$/.test(v);
}
/**
 * Installed version strings for `agent` from `agents view --json` output.
 * Returns `[]` when the agent row is absent, `undefined` when `view` is not
 * JSON (text `agents list` fallback) so callers can degrade.
 */
export function viewAgentVersions(view, agent) {
    try {
        const rows = JSON.parse(view);
        if (!Array.isArray(rows))
            return undefined;
        const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
        if (!row)
            return [];
        return (row.versions ?? [])
            .map((entry) => entry.version)
            .filter((v) => typeof v === 'string' && v.length > 0);
    }
    catch {
        return undefined;
    }
}
/**
 * Whether a concrete `agent@version` is installed according to remote view
 * output. JSON path is authoritative; text listing falls back to a whole-token
 * version match. `undefined` only when the text path has the agent but no
 * version token to confirm against (callers treat that as unverified).
 */
export function viewHasAgentVersion(view, agent, version) {
    const versions = viewAgentVersions(view, agent);
    if (versions !== undefined)
        return versions.includes(version);
    if (!viewHasAgent(view, agent))
        return false;
    const escaped = version.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    if (new RegExp(`\\b${escaped}\\b`).test(view))
        return true;
    return undefined;
}
/** Actionable fail-loud message for a missing remote pin (RUSH-2313). */
export function missingPinnedVersionMessage(hostName, agent, version, installed) {
    let installedHint = '';
    if (installed) {
        installedHint = installed.length > 0
            ? ` Installed on that box: ${installed.join(', ')}.`
            : ` Agent "${agent}" is not installed there.`;
    }
    return (`Pinned ${agent}@${version} is not installed on "${hostName}".${installedHint} ` +
        `Install it on that box: agents ssh ${hostName} -- agents add ${agent}@${version}`);
}
/**
 * Read the two account gates automatic placement needs from `agents view
 * --json`. A picker may route to a signed-out version because launching it is
 * the login flow, but it must not route to a device whose every signed-in
 * account is throttled.
 *
 * The sign-in gate reads the per-version `launchable` field — the strict
 * per-version launch truth (`isLaunchableSignedIn`) — so a remote box is judged
 * by the SAME launchability the local candidate uses (`collectRunCandidates` →
 * `isLaunchableSignedIn`), not the display `signedIn` that inherits the
 * active/global HOME login and passes a box that dies at spawn (PHNX-3466). An
 * older remote CLI omits `launchable`, so it falls back to `signedIn` — the
 * pre-fix behavior, so a rolling fleet does not regress.
 */
export function viewAgentAccountEligibility(view, agent, now = Date.now()) {
    try {
        const rows = JSON.parse(view);
        const row = rows.find((candidate) => candidate.agent?.toLowerCase() === agent.toLowerCase());
        if (!row)
            return { signedIn: undefined, pickerEligible: undefined };
        const verdicts = (row.versions ?? []).flatMap((version) => {
            if (typeof version.signedIn !== 'boolean')
                return [];
            // Prefer the strict per-version launch signal; fall back to the display
            // `signedIn` for an older remote CLI that does not emit `launchable`.
            const launchable = typeof version.launchable === 'boolean' ? version.launchable : version.signedIn;
            const usageCapturedAt = version.usageCapturedAt ? Date.parse(version.usageCapturedAt) : Number.NaN;
            const usageFresh = Number.isFinite(usageCapturedAt)
                ? now - usageCapturedAt <= USAGE_STALE_REFUSAL_MAX_AGE_MS
                : version.usageCapturedAt === undefined;
            const authFresh = typeof version.authCheckedAt === 'number'
                ? now - version.authCheckedAt <= AUTH_PROBE_MAX_AGE_MS
                : version.authCheckedAt === undefined;
            const throttled = usageFresh
                && (version.usageStatus === 'rate_limited' || version.usageStatus === 'out_of_credits');
            const authBlocked = version.authVerdict !== null
                && version.authVerdict !== undefined
                && authFresh
                && isDeadVerdict(version.authVerdict);
            const freshnessKnown = version.usageCapturedAt !== undefined || version.authCheckedAt !== undefined;
            const fresh = !freshnessKnown || (usageFresh && authFresh);
            const ready = launchable && fresh && !authBlocked && !throttled;
            return [{ ready, pickerEligible: ready || !launchable || authBlocked }];
        });
        if (verdicts.length === 0)
            return { signedIn: undefined, pickerEligible: undefined };
        return {
            signedIn: verdicts.some((verdict) => verdict.ready),
            pickerEligible: verdicts.some((verdict) => verdict.pickerEligible),
        };
    }
    catch {
        return { signedIn: undefined, pickerEligible: undefined };
    }
}
export function viewAgentSignedIn(view, agent) {
    return viewAgentAccountEligibility(view, agent).signedIn;
}
/**
 * Pure readiness verdict for the agent/version half of {@link ensureHostReady}
 * (unit-tested without SSH). Returns warnings for soft agent-missing cases, or
 * throws (via the caller's `throw new Error`) when a concrete pin is missing.
 */
export function evaluateHostAgentInstall(view, opts, hostName) {
    const warnings = [];
    if (isConcreteVersionPin(opts.version)) {
        const version = opts.version.trim();
        const installed = viewAgentVersions(view, opts.agent);
        const has = viewHasAgentVersion(view, opts.agent, version);
        if (has === true)
            return { warnings };
        // Missing or unverifiable pin: fail loud. Detached host dispatch used to
        // print "Dispatched" and only die in the remote log ("codex@0.145.0 is not
        // installed") — whole fleet drains no-op'd under that silent success.
        throw new Error(missingPinnedVersionMessage(hostName, opts.agent, version, installed));
    }
    if (!viewHasAgent(view, opts.agent)) {
        const msg = `Agent "${opts.agent}" may not be installed on "${hostName}" (remote \`agents add ${opts.agent}\` to install).`;
        if (opts.requireAgent)
            throw new Error(msg);
        warnings.push(msg);
    }
    return { warnings };
}
/**
 * Verify a host can run the agent: reachable + agents-cli present. Throws with an
 * actionable message otherwise. Agent-not-installed is a warning by default (the
 * remote `agents run` will surface it); pass requireAgent to make it fatal.
 * A concrete `version` pin always fails loud when that version is absent
 * (RUSH-2313) — never a silent "Dispatched" for a pin the box cannot run.
 *
 * One ssh round-trip (`readyProbe`) covers all three checks.
 */
export function ensureHostReady(host, opts) {
    const target = sshTargetFor(host);
    const probe = readyProbe(target, host.os ?? resolveRemoteOsSync(host.name), hostIdentityArgs(host));
    if (probe.timedOut) {
        throw new Error(`Host "${host.name}" (${target}) did not respond in time — the SSH probe timed out after 20 seconds. ` +
            `The host may be slow to start a login shell (nvm/sdkman init, cold node startup). ` +
            `Retry, or run \`agents ssh ${host.name} agents view\` to confirm manually.`);
    }
    if (!probe.reachable) {
        throw new Error(`Host "${host.name}" (${target}) is not reachable over SSH. Check it's online and key auth works.`);
    }
    if (!probe.version) {
        throw new Error(`agents-cli is not installed on "${host.name}". Install it there first — e.g. \`agents devices update\` to roll it out to registered devices.`);
    }
    return evaluateHostAgentInstall(probe.view, opts, host.name);
}
