/**
 * Push one bundle's values to a remote host over SSH — the provisioning
 * primitive behind `agents secrets export --device` and, from RUSH-1968, behind
 * `agents fleet apply --provision-secrets`.
 *
 * This logic used to live inline in the `export --device` command action. It moved
 * here because `lib/fleet/apply.ts` needs it and a lib MUST NOT import a command
 * module — and because the absence of a callable primitive is part of why
 * `fleet apply` never provisioned secrets at all, leaving an operator to
 * hand-export the file store's master key across the fleet.
 *
 * Two rules shape the shape of this module:
 *
 * - **It never prints.** The lib layer stays `console.*`-free (SEC-14/SEC-16), so
 *   every outcome is returned as data and the CALLER renders it. That is also
 *   what lets `fleet apply` fold a push into its own per-device report instead of
 *   interleaving stray lines into it.
 * - **Resolve once, push N times.** `resolveBundleForPush` is separate because
 *   reading a bundle can prompt (Touch ID); doing it per host would prompt per
 *   host. `export --device a,b,c` resolves once and pushes three times.
 */
import { sshExec, sshExecAsync } from '../ssh-exec.js';
import { remoteShellFor, buildWindowsStdinImportCommand } from '../hosts/remote-cmd.js';
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
import { remoteSecretsRaw, remoteSecretsRawAsync, verifyRemoteKeychainPush, verifyRemoteKeychainPushAsync, keychainWriteFailureMessage, buildRemoteFileImportCommand, credentialTransportSshOpts, } from './remote.js';
import { readAndResolveBundleEnv } from './bundles.js';
/**
 * Serialize a resolved env map to `.env` lines that round-trip losslessly through
 * `parseDotenv` on the remote: `KEY="VALUE"`. parseDotenv strips exactly one outer
 * quote pair and takes the inner bytes verbatim (no unescaping), so any single-line
 * value survives unchanged with no escaping. Newlines would break its line-based
 * parse, so multi-line values are rejected rather than silently corrupted.
 */
export function bundleEnvToDotenv(env) {
    const lines = [];
    for (const [k, v] of Object.entries(env)) {
        if (/[\r\n]/.test(v)) {
            throw new Error(`Key '${k}' has a multi-line value; the SSH .env transport can't carry newlines. ` +
                `Set it directly on the remote with 'agents secrets add ${k} --value-stdin'.`);
        }
        lines.push(`${k}="${v}"`);
    }
    return lines.join('\n') + '\n';
}
/** Preserve the literal-vs-secret schema that dotenv transport cannot encode. */
export function planLiteralRestoration(bundle, literalValues = {}) {
    return Object.entries(literalValues).map(([key, value]) => ({
        key,
        removeArgs: ['remove', bundle, key, '--yes'],
        addArgs: ['add', bundle, key, '--value', value],
    }));
}
/**
 * Read and resolve a bundle once, for pushing to one or more hosts.
 *
 * `keyMode: 'storage'` is fixed — a push always sends storage-shaped values.
 *
 * `agentOnly` is the caller's to decide, and it is NOT a security level: it
 * selects the broker-only read (fail fast with the `agents secrets unlock` hint)
 * over the interactive read (macOS may raise a Touch ID sheet). The headless
 * guard exists because an AGENT LAUNCH must never enumerate the keychain — a
 * per-bundle prefix becomes a broad `agents-cli.` scan after service-name
 * hashing, and macOS evaluates unrelated biometry ACLs during it, one sheet per
 * installed version (bundles.ts, RUSH-2440). A human at a TTY is not that case:
 * they can answer one prompt, and the interactive sibling reads (`view --reveal`,
 * `exec`) already let them. NOT `get` — it is unconditionally `agentOnly` on
 * purpose, because prompting would hang a `$(agents secrets get …)` capture
 * mid-pipeline (SEC-13b). It defaults to `true` so an automated caller that says
 * nothing stays broker-only.
 */
export function resolveBundleForPush(bundle, caller, opts = {}) {
    const agentOnly = opts.agentOnly ?? true;
    const { env } = readAndResolveBundleEnv(bundle, { caller, keyMode: 'storage', agentOnly });
    return { env, dotenv: bundleEnvToDotenv(env), keyCount: Object.keys(env).length };
}
function isPowershellTarget(host) {
    return remoteShellFor(resolveRemoteOsSync(host.split('@').pop() ?? host)) === 'powershell';
}
/** Choose the transport for one push. Pure: registry read in, plan out. */
export function planPushTransport(resolved, bundle, host, opts) {
    const powershell = isPowershellTarget(host);
    if (opts.remoteBackend === 'file') {
        // Both file-backend paths build a POSIX `bash -lc` command. Refuse a Windows
        // target cleanly rather than emit broken PowerShell (fail loud at the
        // boundary, never a silent wrong path).
        if (powershell) {
            return { kind: 'refuse', message: 'file backend export to a Windows target is not yet supported' };
        }
        const { remoteCmd, input } = buildRemoteFileImportCommand(bundle, resolved.dotenv, {
            force: opts.force,
            policyNever: opts.policyNever,
        });
        return { kind: 'ssh', remoteCmd, input, multiplex: false };
    }
    if (powershell) {
        // Keychain on a Windows target: the `agents.ps1` shim doesn't forward
        // ssh-piped stdin to node, so `--from -` would hang. Bridge the piped .env
        // through PowerShell into a temp file and import `--from <file>` (deleted
        // afterwards). Same hardened ssh engine; the .env still only ever crosses
        // the wire over ssh stdin.
        return {
            kind: 'ssh',
            remoteCmd: buildWindowsStdinImportCommand(bundle, { force: opts.force, policyNever: opts.policyNever }),
            input: resolved.dotenv,
            multiplex: false,
        };
    }
    // Keychain on a POSIX target: OS-aware wrapping + the hardened ssh engine
    // (BatchMode, ConnectTimeout, keepalive, control-socket reuse) via the same
    // path the READ inverse (`remoteResolveEnv`) uses.
    return {
        kind: 'remote-secrets',
        args: [
            'import', bundle, '--from', '-',
            ...(opts.force ? ['--force'] : []),
            ...(opts.policyNever ? ['--policy', 'never', '--i-understand'] : []),
        ],
        input: resolved.dotenv,
        multiplex: false,
    };
}
/**
 * Push an already-resolved bundle to ONE host.
 *
 * Drives the remote's own `agents secrets import --from -`, so the values land
 * in the remote's chosen backend and the .env is read off ssh stdin rather than
 * parsed by a remote shell. `import` auto-creates the bundle.
 */
export function pushResolvedBundleToHost(resolved, bundle, host, opts) {
    const fail = (message) => ({ ok: false, host, bundle, keyCount: resolved.keyCount, message });
    const plan = planPushTransport(resolved, bundle, host, opts);
    if (plan.kind === 'refuse')
        return fail(plan.message);
    // Every ssh below rides the credential-transport posture: the managed host key
    // is pinned (a changed key is refused) and the connection never reuses or
    // leaves a persistent authenticated control master to the destination
    // (RUSH-2527, `credentialTransportSshOpts`). The plan's `multiplex: false` is
    // the raw-`ssh` branch's half of that; the `remote-secrets` branch and the
    // read-back/policy/literal follow-ups pass `secret: true` for the same posture.
    const res = plan.kind === 'ssh'
        ? sshExec(host, plan.remoteCmd, { input: plan.input, timeoutMs: opts.timeoutMs, hostKeyOpts: credentialTransportSshOpts(host).hostKeyOpts, multiplex: plan.multiplex })
        : remoteSecretsRaw(host, plan.args, { input: plan.input, timeoutMs: opts.timeoutMs, osLookupName: host, secret: true });
    if (res.code === null) {
        return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
    }
    if (res.code !== 0) {
        const msg = (res.stderr || res.stdout || '').trim();
        return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
    }
    // A successful remote import is not proof the keys are readable. Two silent
    // failures hide behind exit 0:
    //   - keychain: a macOS login keychain is locked under headless SSH, so the
    //     write lands metadata but no readable value items.
    //   - file: a forwarded AGENTS_SECRETS_PASSPHRASE keys ciphertext to a secret
    //     the destination daemon does not hold (PHNX-2371).
    // Read the bundle back the way a later resolve will and FAIL LOUDLY.
    const verdict = verifyRemoteKeychainPush(host, bundle, Object.keys(resolved.env), { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
    if (!verdict.ok) {
        if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
            return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
        }
        if (opts.remoteBackend === 'file') {
            return fail(`${host}: pushed '${bundle}' but the remote could not decrypt it (${verdict.reason}). ` +
                `File-backend export never forwards AGENTS_SECRETS_PASSPHRASE — the remote keys ` +
                `the bundle under its own machine-local key. If the destination still cannot read, ` +
                `unset a stale AGENTS_SECRETS_PASSPHRASE there (\`agents doctor\`).`);
        }
        return fail(`pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
    }
    for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
        const removed = remoteSecretsRaw(host, step.removeArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
        if (removed.code !== 0) {
            const msg = (removed.stderr || removed.stdout || '').trim();
            return fail(`pushed '${bundle}' but could not replace transported ${step.key}${msg ? `: ${msg}` : ''}`);
        }
        const literal = remoteSecretsRaw(host, step.addArgs, { osLookupName: host, secret: true, timeoutMs: opts.timeoutMs });
        if (literal.code !== 0) {
            const msg = (literal.stderr || literal.stdout || '').trim();
            return fail(`pushed '${bundle}' but could not preserve literal ${step.key}${msg ? `: ${msg}` : ''}`);
        }
    }
    const remoteMsg = (res.stdout || '').trim().split('\n').map((l) => l.trim()).filter(Boolean).pop();
    return {
        ok: true,
        host,
        bundle,
        keyCount: resolved.keyCount,
        message: remoteMsg || `${resolved.keyCount} key(s) exported`,
    };
}
/** Resolve and push in one call — for a single host. */
export function pushBundleToHost(bundle, host, opts) {
    return pushResolvedBundleToHost(resolveBundleForPush(bundle, opts.operation, { agentOnly: opts.agentOnly }), bundle, host, opts);
}
/**
 * Async, kill-bounded push for daemon services. It preserves the foreground
 * primitive's transport, read-back verification, and literal restoration; only
 * the SSH execution strategy differs.
 */
export async function pushResolvedBundleToHostAsync(resolved, bundle, host, opts) {
    const fail = (message) => ({ ok: false, host, bundle, keyCount: resolved.keyCount, message });
    const plan = planPushTransport(resolved, bundle, host, opts);
    if (plan.kind === 'refuse')
        return fail(plan.message);
    const res = plan.kind === 'ssh'
        ? await sshExecAsync(host, plan.remoteCmd, {
            input: plan.input,
            timeoutMs: opts.timeoutMs,
            hostKeyOpts: credentialTransportSshOpts(host).hostKeyOpts,
            multiplex: plan.multiplex,
        })
        : await remoteSecretsRawAsync(host, plan.args, {
            input: plan.input,
            timeoutMs: opts.timeoutMs,
            osLookupName: host,
            secret: true,
        });
    if (res.code === null)
        return fail(res.stderr.trim() || (res.timedOut ? 'ssh timed out' : 'ssh failed'));
    if (res.code !== 0) {
        const msg = (res.stderr || res.stdout || '').trim();
        return fail(`remote import failed (exit ${res.code})${msg ? `: ${msg}` : ''}`);
    }
    const verdict = await verifyRemoteKeychainPushAsync(host, bundle, Object.keys(resolved.env), {
        osLookupName: host,
        secret: true,
        timeoutMs: opts.timeoutMs,
    });
    if (!verdict.ok) {
        if (opts.remoteBackend === 'keychain' && verdict.kind === 'locked-keychain') {
            return fail(keychainWriteFailureMessage(host, bundle, verdict.reason));
        }
        if (opts.remoteBackend === 'file') {
            return fail(`${host}: pushed '${bundle}' but the remote could not decrypt it (${verdict.reason}). ` +
                `File-backend export never forwards AGENTS_SECRETS_PASSPHRASE — the remote keys ` +
                `the bundle under its own machine-local key. If the destination still cannot read, ` +
                `unset a stale AGENTS_SECRETS_PASSPHRASE there (\`agents doctor\`).`);
        }
        return fail(`pushed '${bundle}' but could not verify it on the remote: ${verdict.reason}`);
    }
    for (const step of planLiteralRestoration(bundle, opts.literalValues)) {
        const removed = await remoteSecretsRawAsync(host, step.removeArgs, {
            osLookupName: host,
            secret: true,
            timeoutMs: opts.timeoutMs,
        });
        if (removed.code !== 0) {
            const msg = (removed.stderr || removed.stdout || '').trim();
            return fail(`pushed '${bundle}' but could not replace transported ${step.key}${msg ? `: ${msg}` : ''}`);
        }
        const literal = await remoteSecretsRawAsync(host, step.addArgs, {
            osLookupName: host,
            secret: true,
            timeoutMs: opts.timeoutMs,
        });
        if (literal.code !== 0) {
            const msg = (literal.stderr || literal.stdout || '').trim();
            return fail(`pushed '${bundle}' but could not preserve literal ${step.key}${msg ? `: ${msg}` : ''}`);
        }
    }
    const remoteMsg = (res.stdout || '').trim().split('\n').map((line) => line.trim()).filter(Boolean).pop();
    return {
        ok: true,
        host,
        bundle,
        keyCount: resolved.keyCount,
        message: remoteMsg || `${resolved.keyCount} key(s) exported`,
    };
}
/** Resolve locally once, then run the daemon-safe async push. */
export function pushBundleToHostAsync(bundle, host, opts) {
    return pushResolvedBundleToHostAsync(resolveBundleForPush(bundle, opts.operation, { agentOnly: opts.agentOnly }), bundle, host, opts);
}
