/**
 * Portable session bundle — the on-the-wire format behind `agents sessions
 * export` / `import` (RUSH-1710 / RUSH-1711).
 *
 * A bundle is a self-describing NDJSON stream: the FIRST line is a
 * {@link BundleHeader}, every subsequent line is one {@link BundleRecord} (one
 * constituent file of a session). NDJSON — not tar — because the bundle has to
 * pipe cleanly over `agents ssh … export --stdout | … import -` (RUSH-1712)
 * without any external archiver on either box, stays inspectable with `head`,
 * and lets each file body carry its own encryption envelope.
 *
 * This module owns the FORMAT and the import PLACEMENT only; selecting which
 * sessions to export (which needs the session DB) lives in the export command.
 * Placement reuses the sync mirror model verbatim: a foreign machine's session
 * lands at {@link mirrorPath}(spec, originMachine, relKey), exactly where the
 * cross-machine sync writes it — so the existing scanner indexes it as a
 * machine-tagged row and "local always wins" falls out of the scanner's
 * live-home-first dedup with no extra logic here.
 */
import * as fs from 'fs';
import * as path from 'path';
import * as crypto from 'crypto';
import { SYNC_AGENTS, mirrorPath } from './sync/agents.js';
import { redactSecrets } from '../redact.js';
import { encryptTranscript, decryptTranscriptBody } from './sync/transcript-crypto.js';
export const BUNDLE_KIND = 'agents-session-bundle';
export const BUNDLE_VERSION = 1;
/** SHA-256 of a file body, used for the bundle's dedup/conflict check on import. */
function hashContent(content) {
    return crypto.createHash('sha256').update(content).digest('hex');
}
/** Look up the sync spec for an agent id (undefined → agent not sync-representable). */
export function specForAgent(agentId) {
    return SYNC_AGENTS.find(s => s.id === agentId);
}
/** True when an agent's sessions can be represented in a bundle (has a sync spec). */
export function isExportableAgent(agentId) {
    return specForAgent(agentId) !== undefined;
}
/**
 * Read one file and turn it into a bundle record. The hash and size are always
 * computed over the PLAINTEXT (post-redaction) body, so they equal what lands on
 * disk after import — keeping dedup byte-exact whether or not the bundle is
 * encrypted.
 */
export function buildRecord(file, opts) {
    let body = fs.readFileSync(file.absPath, 'utf-8');
    if (opts.redact)
        body = redactSecrets(body, opts.knownSecrets);
    const hash = hashContent(body);
    const size = Buffer.byteLength(body, 'utf-8');
    let stored = body;
    let encrypted = false;
    if (opts.encryptKey) {
        stored = encryptTranscript(body, opts.encryptKey);
        encrypted = true;
    }
    const rec = {
        agent: file.agent,
        machine: file.machine,
        sessionId: file.sessionId,
        relKey: file.relKey,
        size,
        hash,
        encrypted,
        body: stored,
    };
    if (file.label)
        rec.label = file.label;
    return rec;
}
/** Build the header for a set of records. */
export function makeHeader(args) {
    const sessions = new Set(args.records.map(r => `${r.agent}:${r.machine}:${r.sessionId}`)).size;
    return {
        kind: BUNDLE_KIND,
        version: BUNDLE_VERSION,
        exportedAt: args.exportedAt,
        origin: args.origin,
        encrypted: args.encrypted,
        redacted: args.redacted,
        count: args.records.length,
        sessions,
    };
}
/**
 * Merge record sets from several bundles (e.g. a fan-out pull across hosts),
 * deduping by agent + origin machine + session + file so the same session seen
 * from two peers lands once. First occurrence wins.
 */
export function mergeRecords(sets) {
    const seen = new Set();
    const out = [];
    for (const set of sets) {
        for (const r of set) {
            const key = `${r.agent}:${r.machine}:${r.sessionId}:${r.relKey}`;
            if (seen.has(key))
                continue;
            seen.add(key);
            out.push(r);
        }
    }
    return out;
}
/** Serialize a bundle to its NDJSON wire form (header line + one line per record). */
export function serializeBundle(header, records) {
    const lines = [JSON.stringify(header)];
    for (const r of records)
        lines.push(JSON.stringify(r));
    return lines.join('\n') + '\n';
}
/**
 * Write a serialized bundle to disk owner-only (0600). A bundle carries raw
 * transcript bodies — even redacted, never world/group-readable — and encryption
 * is opt-in, so the file mode is the baseline confidentiality guard. `mode` on
 * `writeFileSync` only applies when the file is created, so we `chmod` too to
 * clamp an existing (possibly looser) file on overwrite.
 */
export function writeBundleFile(outPath, wire) {
    fs.writeFileSync(outPath, wire, { encoding: 'utf-8', mode: 0o600 });
    fs.chmodSync(outPath, 0o600);
}
/** Parse an NDJSON bundle, validating the header kind + version. Throws on malformed input. */
export function parseBundle(text) {
    const lines = text.split('\n').filter(l => l.trim().length > 0);
    if (lines.length === 0)
        throw new Error('Empty session bundle.');
    let header;
    try {
        header = JSON.parse(lines[0]);
    }
    catch {
        throw new Error('Malformed session bundle: first line is not JSON.');
    }
    if (!header || header.kind !== BUNDLE_KIND) {
        throw new Error(`Not an agents session bundle (kind=${header?.kind ?? 'missing'}).`);
    }
    if (header.version !== BUNDLE_VERSION) {
        throw new Error(`Unsupported bundle version ${header.version} — this CLI reads v${BUNDLE_VERSION}.`);
    }
    const records = [];
    for (let i = 1; i < lines.length; i++) {
        try {
            records.push(JSON.parse(lines[i]));
        }
        catch {
            throw new Error(`Malformed session bundle: record on line ${i + 1} is not JSON.`);
        }
    }
    return { header, records };
}
/**
 * Compute where each record lands and whether it duplicates / conflicts with an
 * existing file. Pure w.r.t. the filesystem it reads (no writes). Dedup is
 * byte-exact: a target that already holds an identical body is `dup`; a target
 * that holds a DIFFERENT body is `conflict` (only overwritten with --overwrite).
 * An agent with no sync spec is `unknown` and never placed.
 */
export function planImport(bundle, opts) {
    return bundle.records.map((record) => {
        const spec = specForAgent(record.agent);
        if (!spec)
            return { record, targetPath: '', status: 'unknown' };
        const body = decryptTranscriptBody(record.body, opts.decryptKey);
        const bodyHash = hashContent(body);
        const targetPath = mirrorPath(spec, record.machine, record.relKey);
        let status = 'new';
        if (fs.existsSync(targetPath)) {
            const existing = fs.readFileSync(targetPath, 'utf-8');
            status = hashContent(existing) === bodyHash ? 'dup' : 'conflict';
        }
        return { record, targetPath, status };
    });
}
/**
 * Materialize a plan to disk. `dup` records are always skipped (local wins);
 * `conflict` records are replaced only when `overwrite` is set; `unknown` records
 * are counted and skipped.
 */
export function writeImport(plan, opts) {
    const res = { placed: 0, skipped: 0, overwritten: 0, conflicts: 0, unknown: 0 };
    for (const item of plan) {
        if (item.status === 'unknown') {
            res.unknown++;
            continue;
        }
        if (item.status === 'dup') {
            res.skipped++;
            continue;
        }
        if (item.status === 'conflict' && !opts.overwrite) {
            res.conflicts++;
            continue;
        }
        const body = decryptTranscriptBody(item.record.body, opts.decryptKey);
        fs.mkdirSync(path.dirname(item.targetPath), { recursive: true });
        fs.writeFileSync(item.targetPath, body, 'utf-8');
        if (item.status === 'conflict')
            res.overwritten++;
        else
            res.placed++;
    }
    return res;
}
