/**
 * WorkingSetPullCoordinator — P2.2b of multi-machine coherence: the
 * orchestration layer that makes the working-set transfer FIRE — on a topic
 * move (the receiver's deliverMessage onAccepted seam), on demand (the
 * reflex route), and on a returning producer (the staggered pending-pull
 * drain).
 *
 * Spec: docs/specs/WORKING-SET-HANDOFF-SPEC.md §3.3 (trigger — receiver-side,
 * epoch-gated, single-flight), §3.4 (durable pending-pull + staggered drain).
 *
 * Discipline (all inherited-invariant applications):
 *  - Operation key (topic, epoch): at most one pull scheduled per key,
 *    deduped against a DURABLE recent-key window (restart-proof).
 *  - Skips: owner !== self (we are not the owner), owner === prevOwner
 *    (placing-confirm, no real move), prevOwner === self (nothing to fetch
 *    from ourselves).
 *  - Single-flight per topic; ownership is rechecked inside the puller
 *    before EVERY write (the stillCurrent seam).
 *  - Nomination is PLURAL + bounded: every machine the journal (own +
 *    replicas) shows as an artifact-producer for the topic, deduped, capped
 *    at 3, most-recent-first — NOT "the prior owner". Replicas NOMINATE
 *    only; each nominee's LIVE manifest is the authority.
 *  - Staggered drain (§3.4): at most `rearmConcurrency` (default 1)
 *    topic-pull in flight per returning peer — the EXO shape is ONE machine
 *    holding MANY topics' files; N simultaneous pulls would slam a box that
 *    just woke.
 *  - Load-aware: scheduling defers under host pressure; the pull is always
 *    async, NEVER in the message-delivery path.
 */
import fs from 'node:fs';
import path from 'node:path';
export const DEFAULT_REARM_CONCURRENCY = 1;
export const DEFAULT_NOMINEE_CAP = 3;
/** Durable (topic,epoch) op-key window size — mirrors the journal's DEDUPE_WINDOW. */
export const OPKEY_WINDOW = 200;
/** Reflex route min interval per topic (rate limit; concurrent calls coalesce). */
export const DEFAULT_REFLEX_MIN_INTERVAL_MS = 30_000;
export class WorkingSetPullCoordinator {
    d;
    opKeyFile;
    opKeys = null;
    /** Single-flight per topic — concurrent triggers coalesce onto one promise. */
    inFlight = new Map();
    /** Per-peer drain gate (staggered re-arm, §3.4). */
    draining = new Set();
    /** Reflex rate limit per topic. */
    lastReflexAt = new Map();
    constructor(deps) {
        this.d = deps;
        this.opKeyFile = path.join(deps.stateDir, 'state', 'coherence-journal', 'pull-opkeys.json');
    }
    // ── The move trigger (§3.3) — called from the onAccepted seam ────────────
    /**
     * A forwarded message landed and THIS machine is (about to be) the owner.
     * Fire-and-forget: never blocks message delivery. The epoch is read from
     * the live ownership store; prevOwner evidence comes from the journal's
     * placement history.
     */
    onTopicAccepted(topic) {
        void this.trigger(topic, 'move').catch((e) => {
            this.d.logger?.(`trigger failed for topic ${topic}: ${e instanceof Error ? e.message : String(e)}`);
        });
    }
    /** The reflex (§3.3): POST /coherence/fetch-working-set — same pipeline, on demand. */
    async fetchWorkingSet(topic) {
        const nowMs = (this.d.now?.() ?? new Date()).getTime();
        const last = this.lastReflexAt.get(topic) ?? 0;
        const inflight = this.inFlight.get(topic);
        if (inflight)
            return inflight; // coalesce into the single-flight pull
        if (nowMs - last < (this.d.reflexMinIntervalMs ?? DEFAULT_REFLEX_MIN_INTERVAL_MS)) {
            return { topic, scheduled: false, skipReason: 'rate-limited' };
        }
        this.lastReflexAt.set(topic, nowMs);
        // The reflex bypasses the (topic,epoch) op-key dedupe (an explicit ask
        // re-fetches even when the move already pulled) but keeps every other
        // gate — ownership, single-flight, nomination bounds.
        return this.trigger(topic, 'reflex');
    }
    // ── The returning-peer drain (§3.4) ───────────────────────────────────────
    /**
     * Called when the presence pull records a peer online (every pass — the
     * drain itself dedupes). Re-fires outstanding pending-pulls for that peer
     * as a STAGGERED drain: at most rearmConcurrency in flight, the rest
     * queued behind it most-recent-epoch-first.
     */
    onPeerRecorded(machineId) {
        if (this.draining.has(machineId))
            return; // a drain is already running
        void this.drainPeer(machineId).catch((e) => {
            this.d.logger?.(`drain failed for ${machineId}: ${e instanceof Error ? e.message : String(e)}`);
        });
    }
    async drainPeer(machineId) {
        const pending = await this.d.ledger.pendingForPeer(machineId);
        if (!pending.length)
            return;
        this.draining.add(machineId);
        try {
            // Sequential (rearmConcurrency 1) most-recent-epoch-first — pendingForPeer
            // pre-sorts. A >1 setting would batch; the default stays single-file.
            for (const rec of pending) {
                // Live-source records re-arm only when the producer's run has stopped —
                // checked against the nominee's LIVE manifest by the pull itself (a
                // still-live run answers liveSource and the record stays).
                const { owner, epoch } = this.d.ownerOf(rec.topic);
                if (owner !== this.d.ownMachineId) {
                    // We no longer own the topic — the current owner's own pull covers
                    // truth; clear our stale record.
                    await this.d.ledger.clear(rec.topic, rec.epoch, rec.nominee);
                    continue;
                }
                if (epoch != null && epoch > rec.epoch) {
                    await this.d.ledger.supersede(rec.topic, epoch);
                    continue;
                }
                await this.pullFromNominee(rec.topic, rec.epoch ?? 0, rec.nominee);
            }
        }
        finally {
            this.draining.delete(machineId);
        }
    }
    // ── The shared pipeline ────────────────────────────────────────────────────
    async trigger(topic, source) {
        const existing = this.inFlight.get(topic);
        if (existing)
            return existing; // single-flight per topic (§3.3)
        const { owner, epoch } = this.d.ownerOf(topic);
        if (owner !== this.d.ownMachineId)
            return { topic, scheduled: false, skipReason: 'not-owner' };
        if (epoch == null)
            return { topic, scheduled: false, skipReason: 'no-epoch' };
        if (source === 'move') {
            // Durable (topic,epoch) op-key dedupe — restart-proof (§3.3).
            const key = `${topic}:${epoch}`;
            if (this.hasOpKey(key))
                return { topic, scheduled: false, skipReason: 'opkey-deduped' };
            // Skip placing-confirms + self-moves via journal placement evidence.
            const prevOwner = this.prevOwnerOf(topic, epoch);
            if (prevOwner === this.d.ownMachineId) {
                this.recordOpKey(key);
                return { topic, scheduled: false, skipReason: 'no-producers' };
            }
            if (this.d.underPressure?.()) {
                // Deferred, not dropped: file a pending-pull per nominee below would
                // need nomination; the cheap honest defer is to leave the op-key
                // unrecorded so the next accept (or the reflex) re-triggers.
                return { topic, scheduled: false, skipReason: 'pressure-deferred' };
            }
            this.recordOpKey(key);
        }
        const run = this.runPull(topic, epoch);
        this.inFlight.set(topic, run);
        try {
            return await run;
        }
        finally {
            this.inFlight.delete(topic);
        }
    }
    async runPull(topic, epoch) {
        const { nominees, capped } = this.nominate(topic);
        if (!nominees.length) {
            return { topic, scheduled: false, skipReason: 'no-producers', ...(capped.length ? { cappedNominees: capped } : {}) };
        }
        const reports = [];
        for (const nominee of nominees) {
            const r = await this.pullFromNominee(topic, epoch, nominee);
            reports.push(r);
        }
        return { topic, scheduled: true, reports, ...(capped.length ? { cappedNominees: capped } : {}) };
    }
    async pullFromNominee(topic, epoch, nominee) {
        const puller = this.d.makePuller(nominee, topic, epoch);
        if (!puller) {
            await this.d.ledger.file_({ topic, epoch, nominee, reason: 'peer-unreachable' });
            await this.d.ledger.recordAttempt(topic, epoch, nominee);
            return { nominee, report: null, error: 'peer unreachable' };
        }
        try {
            const report = await puller.pullTopic(topic);
            if (report.needsPendingPull) {
                // liveSource / busyExhausted / transport — durable record, re-fired
                // on reappearance or run-stopped. busy does NOT consume an attempt.
                const reason = report.files.some((f) => f.outcome === 'liveSourceDeferred')
                    ? 'live-source'
                    : report.files.some((f) => f.outcome === 'busyExhausted')
                        ? 'busy-exhausted'
                        : 'peer-unreachable';
                await this.d.ledger.file_({ topic, epoch, nominee, reason });
                if (reason === 'peer-unreachable') {
                    await this.d.ledger.recordAttempt(topic, epoch, nominee); // genuine failure only
                }
            }
            else {
                await this.d.ledger.clear(topic, epoch, nominee);
            }
            return { nominee, report };
        }
        catch (e) {
            await this.d.ledger.file_({ topic, epoch, nominee, reason: 'peer-unreachable' });
            await this.d.ledger.recordAttempt(topic, epoch, nominee);
            return { nominee, report: null, error: e instanceof Error ? e.message : String(e) };
        }
    }
    // ── Nomination (§3.3 — plural, bounded, journal-evidenced) ────────────────
    /**
     * Every machine the journal (own + replicas) shows as an autonomous-run
     * producer for the topic, excluding self, deduped, most-recent-first,
     * capped. Replicas NOMINATE only — the nominee's live manifest is the
     * authority when the pull runs.
     */
    nominate(topic) {
        const cap = this.d.nomineeCap ?? DEFAULT_NOMINEE_CAP;
        let entries;
        try {
            entries = this.d.reader.query({ kind: 'autonomous-run', topic, limit: 100 }).entries;
        }
        catch (e) {
            this.d.logger?.(`nomination read failed for topic ${topic}: ${e instanceof Error ? e.message : String(e)}`);
            return { nominees: [], capped: [] };
        }
        const seen = new Set();
        const ordered = [];
        for (const e of entries) {
            // newest-first already (reader merge order)
            if (e.machine === this.d.ownMachineId || seen.has(e.machine))
                continue;
            seen.add(e.machine);
            ordered.push(e.machine);
        }
        return { nominees: ordered.slice(0, cap), capped: ordered.slice(cap) };
    }
    /** Previous owner from the journal's placement history (evidence, not authority). */
    prevOwnerOf(topic, epoch) {
        try {
            const entries = this.d.reader.query({ kind: 'topic-placement', topic, limit: 20 }).entries;
            for (const e of entries) {
                const data = e.data;
                if (data.epoch === epoch && typeof data.prevOwner === 'string')
                    return data.prevOwner;
            }
        }
        catch { /* @silent-fallback-ok: missing placement evidence means no prevOwner skip — the pull proceeds and the manifest decides (WORKING-SET-HANDOFF-SPEC §3.3) */
        }
        return null;
    }
    // ── Durable (topic,epoch) op-key window (§3.3 — restart-proof) ────────────
    loadOpKeys() {
        if (this.opKeys)
            return this.opKeys;
        try {
            const parsed = JSON.parse(fs.readFileSync(this.opKeyFile, 'utf-8'));
            this.opKeys = Array.isArray(parsed?.keys) ? parsed.keys.filter((k) => typeof k === 'string') : [];
        }
        catch { /* @silent-fallback-ok: an absent/corrupt op-key window only weakens dedupe for one pull (re-pull is idempotent via skippedExisting) — never a failure (WORKING-SET-HANDOFF-SPEC §3.3) */
            this.opKeys = [];
        }
        return this.opKeys;
    }
    hasOpKey(key) {
        return this.loadOpKeys().includes(key);
    }
    recordOpKey(key) {
        const keys = this.loadOpKeys();
        keys.push(key);
        while (keys.length > OPKEY_WINDOW)
            keys.shift();
        try {
            fs.mkdirSync(path.dirname(this.opKeyFile), { recursive: true });
            const tmp = `${this.opKeyFile}.tmp-${process.pid}`;
            fs.writeFileSync(tmp, JSON.stringify({ version: 1, keys }));
            fs.renameSync(tmp, this.opKeyFile);
        }
        catch (e) { /* @silent-fallback-ok: a failed op-key persist only weakens dedupe across a restart (re-pull is idempotent); logged, never thrown (WORKING-SET-HANDOFF-SPEC §3.3) */
            this.d.logger?.(`op-key persist failed: ${e instanceof Error ? e.message : String(e)}`);
        }
    }
    // ── The slow tick (the server calls this every ~10 min) ───────────────────
    /**
     * TTL sweep + the run-stopped re-arm (§3.4): live-source records re-fire on
     * a cadence — the pull itself is the live-ness check (a still-live run
     * answers `liveSource` again and the record simply stays, attempt-free).
     * Bounded: only existing live-source records for topics we still own.
     */
    async sweep() {
        await this.d.ledger.sweepExpired();
        const all = await this.d.ledger.all();
        for (const rec of all) {
            if (rec.reason !== 'live-source')
                continue;
            const { owner, epoch } = this.d.ownerOf(rec.topic);
            if (owner !== this.d.ownMachineId) {
                await this.d.ledger.clear(rec.topic, rec.epoch, rec.nominee);
                continue;
            }
            if (epoch != null && epoch > rec.epoch) {
                await this.d.ledger.supersede(rec.topic, epoch);
                continue;
            }
            await this.pullFromNominee(rec.topic, rec.epoch, rec.nominee);
        }
    }
}
//# sourceMappingURL=WorkingSetPullCoordinator.js.map