/**
 * Shared cross-machine fan-out for JSON-producing `agents` commands.
 *
 * Registered online devices are queried in parallel over the canonical SSH
 * transport. A recursion-guard environment variable makes each peer answer for
 * itself, and version-skewed or unreachable peers are skipped without hiding
 * healthy results from the rest of the fleet.
 */
import { spawn } from 'child_process';
import { setMaxListeners } from 'node:events';
import chalk from 'chalk';
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote, REMOTE_STDOUT_MAX_BYTES, RemoteUtf8Accumulator, } from './ssh-exec.js';
import { deviceIdentityArgs, sshTargetFor } from './devices/connect.js';
import { resolveExplicitTargets } from './devices/resolve-target.js';
import { loadDevices, isDialableDevice } from './devices/registry.js';
import { remoteShellFor, buildWindowsAgentsCommand, stripClixml } from './hosts/remote-cmd.js';
import { machineId, normalizeHost } from './machine-id.js';
const REMOTE_TIMEOUT_MS = 12_000;
export function normalizeRemoteAgentsJsonParse(parsed) {
    return Array.isArray(parsed) ? { items: parsed, valid: true } : parsed;
}
export function parseRemoteAgentsJsonPayload(stdout, machine, parse) {
    // A Windows peer can prefix the JSON with a PowerShell CLIXML banner; strip it
    // before handing stdout to the command-specific parser (RUSH-2286).
    const parsed = normalizeRemoteAgentsJsonParse(parse(stripClixml(stdout), machine));
    return parsed.valid
        ? { items: parsed.items, parseFailed: false }
        : { items: [], parseFailed: true };
}
/** Build the command one peer runs, with a guard that prevents recursive fan-out. */
export function remoteAgentsJsonCommand(args, noFanoutEnv, os) {
    if (remoteShellFor(os) === 'powershell') {
        return buildWindowsAgentsCommand({ args, env: { [noFanoutEnv]: '1' } });
    }
    const inner = `${noFanoutEnv}=1 agents ${args.map(shellQuote).join(' ')}`;
    return `bash -lc ${shellQuote(inner)}`;
}
/**
 * Stream one child's stdout into memory under a hard per-peer byte ceiling.
 *
 * A cross-machine fan-out awaits every peer's capture under one `Promise.all`,
 * so an unbounded buffer means a single runaway peer (a corrupt or
 * pathologically large payload) exhausts the caller's heap and takes the whole
 * sweep down — RUSH-2065. Once the accumulated bytes would exceed
 * {@link REMOTE_STDOUT_MAX_BYTES}, the child is SIGKILLed and the capture settles
 * as `code: null` (unreachable) rather than trust a truncated body. A definitive
 * early-exit `signal` SIGTERMs the child the same way, and the timer is the
 * per-peer deadline.
 */
export function captureBoundedStdout(child, { timeoutMs, signal }) {
    return new Promise((resolve) => {
        const decoded = new RemoteUtf8Accumulator();
        let stdoutBytes = 0;
        let settled = false;
        const done = (code) => {
            if (settled)
                return;
            settled = true;
            clearTimeout(timer);
            signal?.removeEventListener('abort', onAbort);
            // A killed/aborted capture never cleanly ends its decoder; return what
            // decoded so far (the caller treats a non-zero code as unreachable and
            // ignores stdout anyway).
            resolve({ code, stdout: code === null ? decoded.current() : decoded.end() });
        };
        const timer = setTimeout(() => {
            child.kill('SIGKILL');
            done(null);
        }, timeoutMs);
        // A definitive hit on a fast peer cancels the rest: SIGTERM the child so an
        // unreachable peer is not left running to its full timeout.
        const onAbort = () => { child.kill('SIGTERM'); done(null); };
        signal?.addEventListener('abort', onAbort, { once: true });
        child.stdout?.on('data', (data) => {
            // Over the ceiling, SIGKILL the child and treat it as unreachable rather
            // than trust a partial payload — the RUSH-2065 OOM guard.
            if (stdoutBytes + data.byteLength > REMOTE_STDOUT_MAX_BYTES) {
                child.kill('SIGKILL');
                done(null);
                return;
            }
            stdoutBytes += data.byteLength;
            decoded.write(data);
        });
        child.on('error', () => done(null));
        child.on('close', (code) => done(code ?? null));
    });
}
const sshCapture = (target, remoteCmd, { timeoutMs, signal, extraSshArgs }) => {
    assertValidSshTarget(target);
    if (signal?.aborted)
        return Promise.resolve({ code: null, stdout: '' });
    const args = [...SSH_OPTS, ...controlOpts(), ...(extraSshArgs ?? []), target, remoteCmd];
    const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
    return captureBoundedStdout(child, { timeoutMs, signal });
};
/** Query explicit hosts, or every registered online peer when hosts is omitted. */
export async function gatherRemoteAgentsJson(options, deps = {}) {
    const capture = deps.capture ?? sshCapture;
    const self = machineId();
    const targets = [];
    if (options.hosts && options.hosts.length > 0) {
        targets.push(...await resolveExplicitTargets(options.hosts));
    }
    else {
        let devices;
        try {
            devices = await loadDevices();
        }
        catch {
            return { items: [], deviceCount: 0, skipped: [], parseFailed: [], discoveryFailed: true };
        }
        for (const device of Object.values(devices)) {
            // Live SSH-probe verdict first, cached tailscale snapshot only as a
            // fallback — see isDialableDevice (mirrors session/remote-list.ts).
            if (!isDialableDevice(device))
                continue;
            if (normalizeHost(device.name) === self)
                continue;
            if (!['windows', 'linux', 'macos'].includes(device.platform))
                continue;
            try {
                targets.push({
                    target: sshTargetFor(device),
                    machine: normalizeHost(device.name),
                    name: device.name,
                    os: device.platform,
                    extraSshArgs: deviceIdentityArgs(device),
                });
            }
            catch {
                // A registered profile without a dialable address is not a peer yet.
            }
        }
    }
    const skipped = [];
    const parseFailed = [];
    // The controller exists ONLY for early-exit: the first definitive hit aborts
    // every still-outstanding peer. When earlyExit is not requested there is no
    // controller and no per-peer abort listener, so the default path is a plain
    // all-settle Promise.all, byte-identical to before (tool-search, program-count).
    const controller = options.earlyExit ? new AbortController() : undefined;
    // A fleet can hold more peers than Node's default 10-listener cap; each peer's
    // capture adds one abort listener, so lift the cap to avoid a spurious warning.
    if (controller)
        setMaxListeners(targets.length + 1, controller.signal);
    let earlyResolved = false;
    const results = await Promise.all(targets.map(async (target) => {
        const command = remoteAgentsJsonCommand(options.args, options.noFanoutEnv, target.os);
        const result = await capture(target.target, command, {
            timeoutMs: options.timeoutMs ?? REMOTE_TIMEOUT_MS,
            signal: controller?.signal,
            extraSshArgs: target.extraSshArgs,
        });
        // A peer we deliberately cancelled is neither a hit nor a failure — it never
        // got to answer, so it must not pollute skipped/parseFailed (which drive the
        // "unreachable" listing and the fail-closed partial-resolution gate).
        const cancelled = controller?.signal.aborted ?? false;
        if (result.code !== 0) {
            if (!cancelled) {
                skipped.push(target.name);
                if (!options.quiet) {
                    process.stderr.write(chalk.gray(`  ${target.name}: unreachable or no agents CLI — skipped\n`));
                }
            }
            return [];
        }
        const parsed = parseRemoteAgentsJsonPayload(result.stdout, target.machine, options.parse);
        if (parsed.parseFailed) {
            if (!cancelled) {
                parseFailed.push(target.name);
                if (!options.quiet) {
                    process.stderr.write(chalk.gray(`  ${target.name}: unreachable or no agents CLI — skipped\n`));
                }
            }
            return [];
        }
        if (controller && options.earlyExit && !earlyResolved
            && parsed.items.some((item) => options.earlyExit.isDefinitive(item, target.machine))) {
            earlyResolved = true;
            controller.abort(); // SIGTERM the remaining peers; they settle fast below.
        }
        return parsed.items;
    }));
    return { items: results.flat(), deviceCount: targets.length, skipped, parseFailed, discoveryFailed: false };
}
