/**
 * Adopt-bridge turn attribution state machine.
 *
 * Pure (no fs / IPC / timers) so the worker can wrap it with watchers and
 * tests can drive it deterministically. The worker feeds it transcript
 * events (already drained from JSONL) and Lark-message markers; this class
 * decides which assistant uuids belong to which Lark turn.
 *
 * Attribution rule:
 *   - mark()           — pushes a new pending turn entry (state: not started)
 *   - ingest(events)   — for each new user/assistant event:
 *       * user event → the earliest unstarted pending turn whose fingerprint
 *         matches becomes 'started' (its assistantUuids will collect from
 *         now on). A user event that does NOT match any pending fingerprint
 *         (or arrives with no pending Lark turn at all) is treated as
 *         **local terminal input**: a synthetic local turn is created on
 *         the spot, started immediately, and inserted ahead of any
 *         still-unstarted Lark turns so emit ordering reflects when the
 *         user event actually landed in the transcript. The local turn is
 *         emitted with `isLocal: true` so the worker can format it with a
 *         "user typed in the terminal" marker for the Lark thread.
 *       * assistant text event (non-sidechain) → appended to the
 *         currently-collecting turn (Lark or local), if any.
 *   - drainEmittable() — pops any leading turn that has been started AND has
 *     accumulated at least one visible assistant-text uuid. Started turns with no text
 *     yet (Claude is mid-tool-use) stay queued for the next idle.
 *
 * Baseline (`absorb()`) takes a batch of historical events and registers
 * their uuids as already-seen so future ingest doesn't double-attribute.
 */
import { normaliseForFingerprint, isMeaningfulUserEvent, isMeaningfulQueuedCommand, extractTurnStartText } from './claude-transcript.js';
// Re-export so existing callers (worker.ts, tests) don't need to change
// their import path now that these helpers live in claude-transcript.ts.
export { normaliseForFingerprint };
function assistantHasVisibleText(content) {
    if (typeof content === 'string')
        return content.length > 0;
    if (!Array.isArray(content))
        return false;
    return content.some((block) => block?.type === 'text' && typeof block.text === 'string' && block.text.length > 0);
}
/** Trim a Lark message into a stable fingerprint. Keeps a leading window
 *  of non-whitespace-collapsed content; long enough to disambiguate, short
 *  enough that minor formatting differences (newlines, attachment hints
 *  appended below) don't break the match. */
export function makeFingerprint(message, len = 30) {
    if (typeof message !== 'string')
        return undefined;
    const collapsed = normaliseForFingerprint(message);
    if (collapsed.length === 0)
        return undefined;
    return collapsed.substring(0, len);
}
export class BridgeTurnQueue {
    seen = new Set();
    queue = [];
    collecting = null;
    /** Register events as historical — their uuids are now considered seen
     *  but no attribution happens. Used at attach time to baseline. */
    absorb(events) {
        for (const ev of events) {
            if (ev.uuid)
                this.seen.add(ev.uuid);
        }
    }
    /** Push a new pending turn for the next Lark message. `contentFingerprint`
     *  (when set) restricts which user event can start this turn — only a
     *  user event whose content contains the fingerprint qualifies. Pass
     *  `undefined` to start on the next user event regardless (legacy).
     *
     *  `markTimeMs` is captured here so the rotation fallback can bound its
     *  fingerprint scan to events written after this point — protects short
     *  fingerprints from matching old history in unrelated sibling jsonls. */
    mark(turnId, contentFingerprint, markTimeMs = Date.now(), contentNormalized) {
        this.queue.push({
            turnId,
            started: false,
            assistantUuids: [],
            contentFingerprint,
            contentNormalized,
            markTimeMs,
        });
        return turnId;
    }
    /** Drop all pending turns. Used when the worker discovers it can't
     *  reliably attribute future events (e.g. baseline raced with a turn
     *  already in flight) and wants to clear the slate. */
    clearPending() {
        const dropped = this.queue.splice(0);
        if (this.collecting && dropped.includes(this.collecting))
            this.collecting = null;
        return dropped;
    }
    /** Drop a specific pending turn by turnId iff it has not yet started
     *  collecting assistant text. Returns the dropped turn or null if not
     *  found / already started. Used by the worker when a writeInput's
     *  deferred recheck conclusively fails — the user has been notified
     *  the message was lost, so keeping a fingerprint-bearing mark around
     *  only fuels the per-tick rotation-fallback scan that already
     *  spammed 99% CPU once (no jsonl line will ever match). */
    dropPendingTurn(turnId) {
        const idx = this.queue.findIndex(t => t.turnId === turnId && !t.started);
        if (idx === -1)
            return null;
        const [dropped] = this.queue.splice(idx, 1);
        return dropped;
    }
    /** Sweep pending (unstarted) turns whose mark is older than `maxAgeMs`.
     *  Returns the dropped turns for logging. Belt-and-braces backstop for
     *  any future code path that leaves an unstarted mark stranded — without
     *  it, `maybeSwitchBridgeJsonl` would keep doing full-directory jsonl
     *  scans every poll tick until the worker restarts. Started turns are
     *  never expired here: once Claude actually wrote the user line, the
     *  turn is collecting assistant text and we want to wait however long
     *  the model takes. */
    pruneExpired(maxAgeMs, now = Date.now()) {
        const dropped = [];
        this.queue = this.queue.filter(t => {
            if (t.started)
                return true;
            if (t.markTimeMs === undefined)
                return true;
            if (now - t.markTimeMs <= maxAgeMs)
                return true;
            dropped.push(t);
            return false;
        });
        return dropped;
    }
    /** Process newly-appended events. Idempotent on uuid: events with seen
     *  uuids are skipped, so callers can safely replay.
     *
     *  `sourceJsonlPath` (when provided) is stamped onto a turn at the moment
     *  it transitions from "pending" to "started" — so that emit-time text
     *  resolution reads the same transcript file the user/assistant uuids
     *  were originally observed in. Without this, a sessionId rotation
     *  between ingest and emit would silently drop the reply, since the
     *  global current jsonl path would no longer contain those uuids. */
    ingest(events, sourceJsonlPath) {
        for (const ev of events) {
            const uuid = ev.uuid;
            if (!uuid || this.seen.has(uuid))
                continue;
            this.seen.add(uuid);
            const role = ev.message?.role ?? ev.type;
            if (role === 'user') {
                // Skip ALL non-meaningful user events: tool_result (intra-turn
                // machinery), `<command-name>/clear</command-name>` and other
                // slash-command wrappers (Claude rewrites them after /clear /
                // /resume — same in-process rotation that broke bridge tracking
                // before), isMeta / isCompactSummary markers, sidechain spawns,
                // empty content. These are NOT real user input; treating them as
                // turn boundaries would (a) drop `collecting` mid-stream and lose
                // assistant text after them, and (b) let a synthetic line that
                // accidentally contains the fingerprint substring start the
                // wrong turn.
                if (!isMeaningfulUserEvent(ev))
                    continue;
                this.handleTurnStart(uuid, ev, sourceJsonlPath);
            }
            else if (ev.type === 'attachment' && ev.attachment?.type === 'queued_command') {
                // Type-ahead path: Claude writes `attachment(queued_command)` the
                // moment it dequeues a queued submit, immediately before the
                // assistant text for that turn starts streaming. Equivalent to
                // role:user for turn-start purposes; share the same handler so
                // fingerprint-match / HOL-block-drop / local-turn fallback all
                // apply identically. Without this the bridge attribution queue
                // would skip queued_command events and the type-ahead'd turn's
                // assistant text would either be dropped or attributed to a
                // sibling turn.
                if (!isMeaningfulQueuedCommand(ev))
                    continue;
                this.handleTurnStart(uuid, ev, sourceJsonlPath);
            }
            else if (role === 'assistant') {
                if (ev.isSidechain === true)
                    continue;
                if (!assistantHasVisibleText(ev.message?.content))
                    continue;
                if (!this.collecting) {
                    // Headless local turn: assistant text arrived without any
                    // collecting context. Typical trigger: daemon restart cut off
                    // an in-flight model stream — baseline absorbed the original
                    // user event (uuid added to `seen`) and the worker process lost
                    // its in-memory `collecting` pointer. Without this synthesis the
                    // continuation would be silently dropped (assistantHasVisibleText
                    // events with no `collecting` were just skipped before).
                    // Headless turns have no userUuid; emit-side formatting omits
                    // the user block. Inserted at the head of the unstarted region
                    // so a subsequent normal turn doesn't get reordered ahead of it.
                    const headless = {
                        turnId: `local-headless-${uuid}`,
                        started: true,
                        isLocal: true,
                        userUuid: undefined,
                        assistantUuids: [],
                        sourceJsonlPath,
                        markTimeMs: Date.now(),
                    };
                    const insertAt = this.queue.findIndex(t => !t.started);
                    if (insertAt === -1)
                        this.queue.push(headless);
                    else
                        this.queue.splice(insertAt, 0, headless);
                    this.collecting = headless;
                }
                this.collecting.assistantUuids.push(uuid);
            }
        }
    }
    /** Shared turn-start handler. Called for both `role:user` and
     *  `attachment(queued_command)` events once meaningfulness has been
     *  established by the caller. Encapsulates:
     *    1. HOL-block drop of the previous collecting turn when it got no
     *       assistant text (Claude moved on).
     *    2. Fingerprint-gated start of the earliest unstarted Lark turn,
     *       falling through to local-turn synthesis on mismatch.
     *    3. markTimeMs override to the transcript event's own timestamp —
     *       critical for type-ahead, where the original markTimeMs (set when
     *       the worker wrote to PTY) can be many seconds earlier than the
     *       moment Claude actually dequeues and starts processing the turn.
     *       The bridge-fallback gate's [markTimeMs, nextBoundaryMs) window
     *       MUST anchor on the latter, otherwise a `botmux send` from the
     *       previous turn can leak into the next turn's window and the
     *       suppression decision flips to the wrong turn (real reply
     *       suppressed, fallback shown — exactly what the type-ahead-disable
     *       in commit b2d9791 was protecting against). */
    handleTurnStart(uuid, ev, sourceJsonlPath) {
        // Head-of-line block drop: previous turn never produced any visible
        // assistant text and a new meaningful turn-start has arrived → Claude
        // is single-threaded over the PTY, so the old turn will never get
        // text. Applies to both Lark and local turns.
        if (this.collecting && this.collecting.assistantUuids.length === 0) {
            const idx = this.queue.indexOf(this.collecting);
            if (idx >= 0)
                this.queue.splice(idx, 1);
            this.collecting = null;
        }
        const tsParsed = ev.timestamp ? Date.parse(ev.timestamp) : NaN;
        const eventTimeMs = Number.isFinite(tsParsed) ? tsParsed : Date.now();
        const next = this.queue.find(t => !t.started);
        let consumedNext = false;
        if (next) {
            if (next.contentFingerprint) {
                // Both sides normalised (whitespace-collapsed + trimmed) before
                // the substring check so a transcript line that preserved newlines
                // still matches a fingerprint built from the same text.
                const userText = normaliseForFingerprint(extractTurnStartText(ev));
                if (userText.includes(next.contentFingerprint)) {
                    next.started = true;
                    if (!next.sourceJsonlPath)
                        next.sourceJsonlPath = sourceJsonlPath;
                    next.markTimeMs = eventTimeMs;
                    this.collecting = next;
                    consumedNext = true;
                }
                // Mismatch falls through to local-turn synthesis below.
            }
            else {
                // Legacy mark() with no fingerprint — start on the next turn-start.
                next.started = true;
                if (!next.sourceJsonlPath)
                    next.sourceJsonlPath = sourceJsonlPath;
                next.markTimeMs = eventTimeMs;
                this.collecting = next;
                consumedNext = true;
            }
        }
        if (!consumedNext) {
            // Local-terminal input (or a queued_command whose prompt didn't
            // match any pending Lark fingerprint). Synthesise a started turn
            // ahead of any unstarted Lark turn so chronological order matches
            // transcript order at emit time.
            const localTurn = {
                turnId: `local-${uuid}`,
                started: true,
                isLocal: true,
                userUuid: uuid,
                assistantUuids: [],
                sourceJsonlPath,
                markTimeMs: eventTimeMs,
            };
            const insertAt = this.queue.findIndex(t => !t.started);
            if (insertAt === -1)
                this.queue.push(localTurn);
            else
                this.queue.splice(insertAt, 0, localTurn);
            this.collecting = localTurn;
        }
    }
    /** Pop FIFO any leading turn that's started AND has assistant text.
     *  Returns the popped turns in order; the caller is responsible for
     *  rebuilding the text payload from the assistant uuids. */
    drainEmittable() {
        const out = [];
        while (this.queue.length > 0) {
            const head = this.queue[0];
            if (!head.started || head.assistantUuids.length === 0)
                break;
            this.queue.shift();
            if (this.collecting === head)
                this.collecting = null;
            out.push(head);
        }
        return out;
    }
    /** Number of queued (not-yet-emitted) Lark turns. */
    size() {
        return this.queue.length;
    }
    /** Test helper — peek the queue without mutating. */
    peek() {
        return this.queue;
    }
}
//# sourceMappingURL=bridge-turn-queue.js.map