/**
 * WorkingSetManifest — P2.1 of multi-machine coherence: pure, on-demand
 * computation of "what files make up this topic's workspace on THIS machine".
 *
 * Spec: docs/specs/WORKING-SET-HANDOFF-SPEC.md §3.1 (manifest — computed,
 * never declared). No new persistent store; no willpower-dependent
 * declarations. Sources, deduped:
 *   1. Filesystem convention: `autonomous/<topic>.local.md` + `autonomous/
 *      <topic>.*` (bounded readdir of the convention dir — never recursion).
 *   2. Journal evidence: jailed `artifactPaths` from the topic's OWN-stream
 *      autonomous-run entries (CoherenceJournalReader.readOwnAutonomousRuns —
 *      injected by the caller so this module stays pure).
 *
 * Discipline (§3.1):
 *  - Every candidate is canonicalized + re-jailed HERE even though journal
 *    paths were jailed at write time (jails are enforced at the data's birth
 *    AND at every serve boundary — defense in depth, P1 invariant).
 *  - `mtime` is DISPLAY-ONLY (P1's `ts` treatment): every diff/skip decision
 *    downstream keys on sha256 exclusively.
 *  - The credential-shape scan runs over each candidate file's BYTES; a
 *    flagged file is LISTED `secretFlagged: true` and never transferred — an
 *    honest, surfaced refusal, never a silent skip. The scan is a
 *    LEAK-REDUCTION filter, not the security boundary (the boundary is the
 *    same-operator peer posture — §3.1/§5).
 *  - When the topic's own journal shows a live autonomous run, EVERY entry is
 *    `liveSource: true` (any of them is plausibly mid-write) — the pull
 *    re-fires on the run's `stopped` (§3.4).
 *  - Bounded everywhere: per-file caps (headline exemption for the topic's
 *    `.local.md`), maxFiles, hash ceiling. Over-cap entries are LISTED
 *    `tooLarge: true` — observability is not delivery.
 */
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { redactForLiveTail } from './liveTailRedaction.js';
/** §3.7 defaults (mirrored in ConfigDefaults.coherenceJournal.workingSet). */
export const DEFAULT_WORKING_SET_CAPS = {
    maxFileBytes: 4 * 1024 * 1024,
    headlineFileBytes: 16 * 1024 * 1024,
    maxFiles: 64,
    maxTotalBytes: 32 * 1024 * 1024,
};
/**
 * Above this, a file's sha256 is reported `null` rather than paying an
 * unbounded hash at manifest time (a tooLarge file never transfers anyway,
 * so the hash is disclosure metadata, not verification material).
 */
export const HASH_BYTE_CEILING = 64 * 1024 * 1024;
function realFs() {
    return {
        readdirSync: (dir) => fs.readdirSync(dir),
        lstatSync: (p) => fs.lstatSync(p),
        existsSync: (p) => fs.existsSync(p),
        realpathSync: (p) => fs.realpathSync(p),
        readFileSync: (p) => fs.readFileSync(p),
    };
}
function defaultSecretScan(content) {
    return redactForLiveTail(content.toString('utf-8')).redactedCount > 0;
}
export function computeWorkingSet(opts) {
    const io = opts.fsImpl ?? realFs();
    const caps = mergeCaps(opts.caps);
    const now = opts.now ?? (() => new Date());
    const scan = opts.secretScan ?? defaultSecretScan;
    // Realpath the stateDir itself so the jail roots and the realpathed
    // candidates live in one namespace (macOS: /var/folders → /private/var).
    let stateDir = path.resolve(opts.stateDir);
    try {
        stateDir = io.realpathSync(stateDir);
    }
    catch { /* @silent-fallback-ok: a not-yet-existing stateDir keeps its lexical path; containment still bounds it (WORKING-SET-HANDOFF-SPEC §3.1) */
    }
    const conventionDir = path.join(stateDir, 'autonomous');
    // Same roots as the journal writer's artifactRoots default (§3.1).
    const jailRoots = [conventionDir, stateDir];
    let jailRejected = 0;
    let goneFromDisk = 0;
    // ---- gather candidates (deduped on canonical absolute path) -------------
    const candidates = new Map();
    // Source 1: filesystem convention — bounded readdir, no recursion. Names
    // are matched with a `<topic>.` prefix so topic 134 never matches 13481.
    let names = [];
    try {
        names = io.readdirSync(conventionDir);
    }
    catch { /* @silent-fallback-ok: convention dir absent = no convention files; journal evidence still applies (WORKING-SET-HANDOFF-SPEC §3.1) */
        names = [];
    }
    const prefix = `${opts.topic}.`;
    for (const name of names) {
        if (!name.startsWith(prefix))
            continue;
        candidates.set(path.join(conventionDir, name), {
            abs: path.join(conventionDir, name),
            fromJournal: false,
        });
    }
    // Source 2: journal evidence (already write-time jailed; re-jailed below).
    for (const p of opts.runs.artifactPaths) {
        const abs = path.isAbsolute(p) ? path.resolve(p) : path.resolve(stateDir, p);
        if (!candidates.has(abs))
            candidates.set(abs, { abs, fromJournal: true });
    }
    // ---- jail + stat + hash + scan -------------------------------------------
    const headlineAbs = path.join(conventionDir, `${opts.topic}.local.md`);
    const surviving = [];
    const seenJailed = new Set();
    for (const cand of candidates.values()) {
        // Jail FIRST (works on nonexistent paths too) — an escape-shaped path is
        // jailRejected even when nothing exists at it; only an in-jail path that
        // has vanished counts as benign goneFromDisk.
        const jailed = jailContained(io, jailRoots, cand.abs);
        if (jailed === null) {
            jailRejected++;
            continue;
        }
        // Regular files only — lstat the ORIGINAL path (not the realpathed one) so
        // a symlink at the FINAL component is refused even when its target
        // realpaths inside the jail (serve-time O_NOFOLLOW will refuse it anyway;
        // the manifest must not promise what serve refuses).
        let st;
        try {
            st = io.lstatSync(cand.abs);
        }
        catch { /* @silent-fallback-ok: a journal-evidenced path that vanished is benign evolution, counted as goneFromDisk — never an error (WORKING-SET-HANDOFF-SPEC §3.1) */
            goneFromDisk++;
            continue;
        }
        if (st.isSymbolicLink() || !st.isFile()) {
            jailRejected++;
            continue;
        }
        // Dedupe on the CANONICAL path — a journal path and a convention hit that
        // resolve to the same file are one entry, whatever spelling each used.
        if (seenJailed.has(jailed))
            continue;
        seenJailed.add(jailed);
        const isHeadline = jailed === headlineAbs;
        const cap = isHeadline ? caps.headlineFileBytes : caps.maxFileBytes;
        const tooLarge = st.size > cap;
        let sha256 = null;
        let secretFlagged = false;
        if (st.size <= HASH_BYTE_CEILING) {
            try {
                const content = io.readFileSync(jailed);
                sha256 = crypto.createHash('sha256').update(content).digest('hex');
                // Scan only what could transfer — a tooLarge file never moves, so the
                // read above is its last touch.
                if (!tooLarge)
                    secretFlagged = scan(content);
            }
            catch { /* @silent-fallback-ok: file vanished between lstat and read — benign evolution, counted as goneFromDisk (WORKING-SET-HANDOFF-SPEC §3.1) */
                goneFromDisk++;
                continue;
            }
        }
        surviving.push({
            relPath: path.relative(stateDir, jailed),
            bytes: st.size,
            sha256,
            mtime: new Date(st.mtimeMs).toISOString(),
            ...(tooLarge ? { tooLarge: true } : {}),
            ...(secretFlagged ? { secretFlagged: true } : {}),
        });
    }
    // ---- deterministic order + maxFiles ---------------------------------------
    // Headline first, then alpha on relPath — so truncation at maxFiles never
    // drops the headline and is stable across recomputations.
    const headlineRel = path.relative(stateDir, headlineAbs);
    surviving.sort((a, b) => {
        if (a.relPath === headlineRel)
            return -1;
        if (b.relPath === headlineRel)
            return 1;
        return a.relPath < b.relPath ? -1 : a.relPath > b.relPath ? 1 : 0;
    });
    const filesTruncated = Math.max(0, surviving.length - caps.maxFiles);
    const kept = surviving.slice(0, caps.maxFiles);
    // ---- liveSource (§3.2 live-source honesty — ALL of a live run's entries) --
    if (opts.runs.liveRun) {
        for (const e of kept)
            e.liveSource = true;
    }
    let transferableBytes = 0;
    for (const e of kept) {
        if (!e.tooLarge && !e.secretFlagged && !e.liveSource)
            transferableBytes += e.bytes;
    }
    return {
        topic: opts.topic,
        computedAt: now().toISOString(),
        entries: kept,
        liveRun: opts.runs.liveRun,
        evidenceTruncated: opts.runs.truncated,
        filesTruncated,
        jailRejected,
        goneFromDisk,
        transferableBytes,
    };
}
/**
 * Compute-time jail: realpath the deepest existing ancestor (symlink-escape
 * protection on the existing prefix), require containment under one of the
 * allowlisted roots for BOTH the resolved ancestor and the final path.
 * Mirrors CoherenceJournal's write-time jail (§3.1 — same rules at every
 * boundary).
 */
function jailContained(io, roots, candidate) {
    let existing = candidate;
    const tail = [];
    while (!io.existsSync(existing)) {
        const parent = path.dirname(existing);
        if (parent === existing)
            break;
        tail.unshift(path.basename(existing));
        existing = parent;
    }
    let realExisting;
    try {
        realExisting = io.realpathSync(existing);
    }
    catch { /* @silent-fallback-ok: realpath failure on a vanishing path falls back to the lexical path, which containment still bounds (WORKING-SET-HANDOFF-SPEC §3.1) */
        realExisting = existing;
    }
    const finalPath = tail.length ? path.join(realExisting, ...tail) : realExisting;
    for (const root of roots) {
        if (isContained(root, realExisting) && isContained(root, finalPath))
            return finalPath;
    }
    return null;
}
function isContained(root, p) {
    const rel = path.relative(root, p);
    return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
}
/** Undefined-safe caps merge — a config block with absent keys keeps defaults. */
export function mergeCaps(caps) {
    const out = { ...DEFAULT_WORKING_SET_CAPS };
    if (!caps)
        return out;
    for (const k of Object.keys(out)) {
        const v = caps[k];
        if (typeof v === 'number' && Number.isFinite(v) && v > 0)
            out[k] = v;
    }
    return out;
}
//# sourceMappingURL=WorkingSetManifest.js.map