import Foundation
import Testing
import NucleicProtocol

@testable import NucleicRemoteProjection

/// The safety net required by docs/TRANSCRIPT_INCREMENTAL_PROJECTION.md: replay an event stream
/// and assert `incremental(prefix) == TranscriptProjection.build(prefix)` for **every** prefix.
/// This mechanically proves the seam never reorders items, orphans a lock note, splits a tool
/// run, or bakes a stale message — the entire correctness argument of the incremental projector.
@Suite struct IncrementalProjectionEquivalenceTests {

    // MARK: - Stream builder

    /// Grows a synthetic `AgentEvent` stream with the same shapes the decoders emit — streaming
    /// deltas, whole-message finals, tool lifecycles with trailing file changes, subagent scopes,
    /// lock-lifecycle notes — so the equivalence property is exercised on realistic seams.
    final class Stream {
        private(set) var events: [AgentEvent] = []
        private var seq: UInt64

        init(startSeq: UInt64 = 0) { self.seq = startSeq }

        func add(_ kind: AgentEvent.Kind, nativeType: String? = nil) {
            seq += 1
            events.append(AgentEvent(
                sessionID: SessionID(rawValue: "s"), seq: seq,
                at: Date(timeIntervalSince1970: 1_750_000_000),
                backend: .claudeCode, nativeType: nativeType, kind: kind))
        }

        func started(cwd: String = "/repo/wt") {
            add(.sessionStarted(SessionStarted(
                backendSessionID: "native", model: "claude-fable-5", cwd: cwd, toolNames: [])))
        }
        func user(_ id: String, _ text: String) {
            add(.userText(TextChunk(messageID: id, text: text, isPartial: false)))
        }
        func textDelta(_ id: String, _ text: String, parent: String? = nil) {
            add(.assistantText(TextChunk(messageID: id, text: text, isPartial: true, parentToolCallID: parent)))
        }
        func textFinal(_ id: String, _ text: String, parent: String? = nil) {
            add(.assistantText(TextChunk(messageID: id, text: text, isPartial: false, parentToolCallID: parent)))
        }
        func thinkingDelta(_ id: String, _ text: String, parent: String? = nil) {
            add(.thinking(TextChunk(messageID: id, text: text, isPartial: true, parentToolCallID: parent)))
        }
        func thinkingFinal(_ id: String, _ text: String, parent: String? = nil) {
            add(.thinking(TextChunk(messageID: id, text: text, isPartial: false, parentToolCallID: parent)))
        }
        func toolStart(_ id: String, _ name: String, _ input: JSONValue = .object([:]), parent: String? = nil) {
            add(.toolCallStarted(ToolCall(toolCallID: id, name: name, input: input, parentToolCallID: parent)))
        }
        func toolComplete(_ id: String, _ name: String, _ input: JSONValue = .object([:]), parent: String? = nil) {
            add(.toolCallCompleted(ToolCall(toolCallID: id, name: name, input: input, parentToolCallID: parent)))
        }
        func result(_ id: String, _ content: JSONValue = .string("ok"), isError: Bool = false) {
            add(.toolResult(ToolResult(toolCallID: id, content: content, isError: isError)))
        }
        func fileChange(_ id: String?, _ path: String, _ kind: FileChange.ChangeKind = .update) {
            add(.fileChange(FileChange(path: path, kind: kind, toolCallID: id)))
        }
        func note(_ text: String, icon: String? = nil, lockEvent: Bool = false, lock: NoteLock? = nil,
                  toolCallID: String? = nil) {
            add(.note(NoteEvent(text: text, icon: icon, lockEvent: lockEvent, lock: lock, toolCallID: toolCallID)))
        }
        func usage() { add(.usage(Usage(inputTokens: 100, outputTokens: 20, contextInputTokens: 1000))) }
        func turn() { add(.turnCompleted(TurnCompleted(stopReason: "end_turn", usage: nil))) }
        func finished(_ outcome: RunFinished.Outcome = .completed) {
            add(.runFinished(RunFinished(outcome: outcome)))
        }
        func error(_ message: String) {
            add(.error(AgentError(recoverable: false, message: message)))
        }
        func approval(_ toolName: String) {
            add(.approvalRequested(ApprovalRequest(
                id: ApprovalID(rawValue: "appr-\(seq)"), sessionID: SessionID(rawValue: "s"),
                toolCallID: nil, toolName: toolName, input: .object([:]), title: toolName,
                risk: .execute, createdAt: Date(timeIntervalSince1970: 1_750_000_000))))
        }
        func raw(_ body: String) {
            add(.raw(Raw(native: .string(body))), nativeType: "mystery")
        }

        /// A full edit-class tool lifecycle: Edit start/complete/result + inferred fileChange,
        /// the exact adjacency ClaudeStreamDecoder emits.
        func edit(_ id: String, path: String, parent: String? = nil) {
            let input = JSONValue.object(["file_path": .string(path)])
            toolStart(id, "Edit", input, parent: parent)
            toolComplete(id, "Edit", input, parent: parent)
            result(id)
            fileChange(id, path)
        }
    }

    // MARK: - The property

    /// Replays every prefix of `events` through one live projector instance and diffs it against
    /// the one-shot fold; optionally asserts the watermark actually advanced (so a projector that
    /// degenerates to "never seal" — trivially equivalent — can't pass silently).
    @discardableResult
    func assertEquivalent(
        _ events: [AgentEvent], showRaw: Bool = false, showLockEvents: Bool = true,
        expectSealing: Bool = true, sourceLocation: SourceLocation = #_sourceLocation
    ) -> IncrementalTranscriptProjection {
        let incremental = IncrementalTranscriptProjection()
        for p in 0...events.count {
            let prefix = Array(events.prefix(p))
            let full = TranscriptProjection.build(prefix, showRaw: showRaw, showLockEvents: showLockEvents)
            let live = incremental.items(for: prefix, showRaw: showRaw, showLockEvents: showLockEvents)
            if live != full {
                Issue.record(
                    "diverged at prefix \(p)/\(events.count): incremental \(live.count) items, full \(full.count)",
                    sourceLocation: sourceLocation)
                return incremental
            }
        }
        if expectSealing {
            #expect(incremental.sealedEventCountForTesting > 0,
                    "watermark never advanced — no incremental win", sourceLocation: sourceLocation)
        }
        return incremental
    }

    // MARK: - Scenarios

    @Test func multiTurnStreaming() {
        let s = Stream()
        s.started()
        for t in 0..<4 {
            s.user("u\(t)", "do thing \(t)")
            s.thinkingDelta("m\(t)", "hmm ")
            s.thinkingDelta("m\(t)", "okay")
            s.thinkingFinal("m\(t)", "hmm okay")
            s.textDelta("m\(t)", "work")
            s.textDelta("m\(t)", "ing…")
            s.toolStart("t\(t)a", "Bash", .object(["command": .string("ls")]))
            s.toolComplete("t\(t)a", "Bash", .object(["command": .string("ls")]))
            s.result("t\(t)a", .string("files"))
            s.textFinal("m\(t)", "working…")
            s.usage()
            s.turn()
        }
        s.finished()
        let projector = assertEquivalent(s.events)
        // The frozen turns must actually be sealed — the live tail is the current turn, not
        // the whole session.
        #expect(projector.sealedEventCountForTesting > s.events.count / 2)
    }

    @Test func toolRunsAndEmptyThinking() {
        let s = Stream()
        s.started()
        s.user("u0", "go")
        // A run of tools with a redacted (empty) thinking block in the middle: held rows must
        // not split the run, and the seam must not land inside it.
        s.textFinal("m0", "let me look")
        s.toolStart("t1", "Read"); s.toolComplete("t1", "Read"); s.result("t1")
        s.thinkingFinal("empty", "")
        s.toolStart("t2", "Grep"); s.toolComplete("t2", "Grep"); s.result("t2")
        s.toolStart("t3", "Read"); s.toolComplete("t3", "Read"); s.result("t3")
        s.textFinal("m1", "found it")
        s.turn()
        s.user("u1", "next")
        s.textFinal("m2", "on it")
        s.turn()
        assertEquivalent(s.events)
    }

    @Test func subagentNestingWithGrandchild() {
        let s = Stream()
        s.started()
        s.user("u0", "fan out")
        s.textFinal("m0", "spawning")
        s.toolStart("task1", "Task", .object(["prompt": .string("explore")]))
        s.toolComplete("task1", "Task", .object(["prompt": .string("explore")]))
        // Child activity interleaves with the parent's own stream.
        s.thinkingFinal("c1", "child thinks", parent: "task1")
        s.toolStart("ct1", "Read", .object(["file_path": .string("/repo/wt/a.txt")]), parent: "task1")
        s.toolComplete("ct1", "Read", .object(["file_path": .string("/repo/wt/a.txt")]), parent: "task1")
        s.result("ct1")
        // Grandchild: the child spawns its own Task.
        s.toolStart("task2", "Task", parent: "task1")
        s.toolComplete("task2", "Task", parent: "task1")
        s.textFinal("g1", "grandchild prose", parent: "task2")
        s.result("task2", .string("inner done"))
        s.textFinal("c2", "child wraps up", parent: "task1")
        s.result("task1", .string("done"))
        s.textFinal("m1", "all merged")
        s.turn()
        s.user("u1", "again")
        s.textFinal("m2", "ok")
        s.turn()
        assertEquivalent(s.events)
    }

    @Test func fanOutBlockOfTasks() {
        let s = Stream()
        s.started()
        s.user("u0", "parallel")
        s.textFinal("m0", "spawning two")
        s.toolStart("A", "Task"); s.toolComplete("A", "Task")
        s.toolStart("B", "Task"); s.toolComplete("B", "Task")
        s.textFinal("a1", "agent A working", parent: "A")
        s.textFinal("b1", "agent B working", parent: "B")
        s.result("A", .string("a done"))
        s.result("B", .string("b done"))
        s.textFinal("m1", "both returned")
        s.turn()
        s.user("u1", "…")
        s.textFinal("m2", "next")
        s.turn()
        assertEquivalent(s.events)
    }

    @Test func lockNotesFoldWithinTurn() {
        let s = Stream()
        s.started()
        s.user("u0", "edit please")
        s.textFinal("m0", "editing")
        s.edit("e1", path: "/repo/wt/src/a.swift")
        s.note("Lock acquired on src/a.swift.", icon: "lock.fill", lockEvent: true,
               lock: NoteLock(state: .acquired, paths: ["src/a.swift"]))
        s.note("Lock released on src/a.swift.", icon: "lock.open", lockEvent: true,
               lock: NoteLock(state: .released, paths: ["src/a.swift"]))
        s.textFinal("m1", "done")
        s.turn()
        assertEquivalent(s.events)
        assertEquivalent(s.events, showLockEvents: false)
    }

    /// A lock/nvrsion note *tagged* with a subagent's edit nests in that subagent's card instead of
    /// leaking into the main transcript; an untagged note (a turn-end release, or a main-agent
    /// edit's note) stays at the top level. Also checks the incremental projector agrees across
    /// every prefix — a routed note stays on its subagent's side of every seal seam.
    @Test func taggedNotesNestInSubagentCard() {
        let s = Stream()
        s.started()
        s.user("u0", "delegate an edit")
        s.textFinal("m0", "spawning a subagent")
        s.toolStart("A", "Task"); s.toolComplete("A", "Task")
        s.textFinal("a0", "on it", parent: "A")
        // The subagent edits a file; its lock-acquire and nvrsion-land notes carry the edit's id
        // (a child of Task A), so they must nest in A's card, not the main transcript.
        s.edit("e1", path: "/repo/wt/src/a.swift", parent: "A")
        s.note("Lock acquired on src/a.swift.", icon: "lock.fill", lockEvent: true,
               lock: NoteLock(state: .acquired, paths: ["src/a.swift"]), toolCallID: "e1")
        s.note("nvrsion: landed src/a.swift · abc1234", icon: "checkmark.circle", toolCallID: "e1")
        s.result("A", .string("edited"))
        s.textFinal("m1", "subagent done")
        s.turn()
        // The turn-end release is untagged: it lands after A returned, so it stays top level — and
        // has no *top-level* edit of a.swift to fold onto, so it renders as a standalone row.
        s.note("Lock released on src/a.swift.", icon: "lock.open", lockEvent: true,
               lock: NoteLock(state: .released, paths: ["src/a.swift"]))
        s.textFinal("m2", "landed")
        s.turn()

        assertEquivalent(s.events)
        assertEquivalent(s.events, showLockEvents: false)

        let items = TranscriptProjection.build(s.events, showRaw: false, showLockEvents: true)
        let topNotes = Self.noteTexts(items)                     // top level only, no descent
        let cardNotes = Self.noteTexts(Self.subagentChildren(items, cardID: "A"))
        // The subagent's tagged notes are inside its card…
        #expect(cardNotes.contains { $0.hasPrefix("Lock acquired on src/a.swift") })
        #expect(cardNotes.contains { $0.hasPrefix("nvrsion: landed src/a.swift") })
        // …and not leaking into the main transcript.
        #expect(!topNotes.contains { $0.contains("src/a.swift") && $0.hasPrefix("Lock acquired") })
        #expect(!topNotes.contains { $0.hasPrefix("nvrsion:") })
        // The untagged release stays top level, not pulled into the subagent's card.
        #expect(topNotes.contains { $0.hasPrefix("Lock released on src/a.swift") })
        #expect(!cardNotes.contains { $0.hasPrefix("Lock released") })
    }

    /// Note texts directly in `items` (not descending into subagent children).
    private static func noteTexts(_ items: [TranscriptItem]) -> [String] {
        items.compactMap { if case .note(let text, _, _, _, _) = $0.kind { return text } else { return nil } }
    }

    /// The `children` of the subagent spawn whose tool-call id is `cardID`, wherever it renders
    /// (a lone `.tool` card or inside a coalesced `.toolBlock`).
    private static func subagentChildren(_ items: [TranscriptItem], cardID: String) -> [TranscriptItem] {
        for item in items {
            switch item.kind {
            case .tool(let g) where g.toolCallID == cardID: return g.children
            case .toolBlock(let groups):
                if let g = groups.first(where: { $0.toolCallID == cardID }) { return g.children }
            default: break
            }
        }
        return []
    }

    /// The hard case the registry exists for: a lock's `released` note lands when the file lands
    /// in the parent — several turns after the edit card it must fold onto is sealed.
    @Test func lockReleaseManyTurnsAfterEdit() {
        let s = Stream()
        s.started()
        s.user("u0", "edit")
        s.textFinal("m0", "editing")
        s.edit("e1", path: "/repo/wt/src/deep/file.swift")
        s.note("Lock acquired on src/deep/file.swift.", lockEvent: true,
               lock: NoteLock(state: .acquired, paths: ["src/deep/file.swift"]))
        s.textFinal("m1", "edited")
        s.turn()
        // Two full unrelated turns so the edit is deeply sealed.
        for t in 1...2 {
            s.user("u\(t)", "other work")
            s.textFinal("mx\(t)", "sure")
            s.toolStart("t\(t)", "Bash"); s.toolComplete("t\(t)", "Bash"); s.result("t\(t)")
            s.textFinal("my\(t)", "done")
            s.turn()
        }
        // The landing releases the lock — the note must fold onto the sealed edit card.
        s.note("Lock released on src/deep/file.swift.", lockEvent: true,
               lock: NoteLock(state: .released, paths: ["src/deep/file.swift"]))
        s.textFinal("m9", "shipped")
        s.turn()
        s.user("u9", "tail")
        s.textFinal("m10", "yep")
        s.turn()
        assertEquivalent(s.events)
    }

    @Test func multiPathAndUnmatchedLockNotes() {
        let s = Stream()
        s.started()
        s.user("u0", "edit two files")
        s.textFinal("m0", "editing both")
        s.edit("e1", path: "/repo/wt/src/a.swift")
        s.edit("e2", path: "/repo/wt/src/b.swift")
        // Multi-path note routes each path to its own card.
        s.note("Lock acquired on src/a.swift, src/b.swift.", lockEvent: true,
               lock: NoteLock(state: .acquired, paths: ["src/a.swift", "src/b.swift"]))
        // Unmatched path → the whole note stays a standalone row.
        s.note("Waiting for a lock on src/other.swift…", lockEvent: true,
               lock: NoteLock(state: .waiting, paths: ["src/other.swift"]))
        // Lock-flagged note with no structured paths (re-ground) → standalone, never folded.
        s.note("Re-grounding on src/a.swift: the parent advanced.", lockEvent: true)
        s.textFinal("m1", "done")
        s.turn()
        s.user("u1", "…")
        s.textFinal("m2", "next")
        s.turn()
        // Cross-turn: a later multi-path release folding onto two *sealed* cards.
        s.note("Lock released on src/a.swift, src/b.swift.", lockEvent: true,
               lock: NoteLock(state: .released, paths: ["src/a.swift", "src/b.swift"]))
        s.textFinal("m3", "landed")
        s.turn()
        assertEquivalent(s.events)
        assertEquivalent(s.events, showLockEvents: false)
    }

    /// Claude's stream shape: partial deltas for a message, then tool items, then the
    /// whole-message decode re-emits the same messageID's authoritative non-partial text — a
    /// message must not seal while its final chunks can still arrive.
    @Test func messageFinalArrivesAfterToolItems() {
        let s = Stream()
        s.started()
        s.user("u0", "go")
        s.textDelta("m0", "let me ")
        s.textDelta("m0", "check")
        s.toolStart("t1", "Read")
        s.textFinal("m0", "let me check — rephrased")  // authoritative replacement
        s.toolComplete("t1", "Read")
        s.result("t1")
        s.textFinal("m1", "found")
        s.turn()
        s.user("u1", "more")
        s.textFinal("m2", "ok")
        s.turn()
        assertEquivalent(s.events)
    }

    @Test func interruptedRunLeavesToolUnfinished() {
        let s = Stream()
        s.started()
        s.user("u0", "run forever")
        s.textFinal("m0", "starting")
        s.toolStart("t1", "Bash", .object(["command": .string("sleep 1000")]))
        s.toolComplete("t1", "Bash", .object(["command": .string("sleep 1000")]))
        // No result — the user interrupts.
        s.finished(.interrupted)
        s.turn()
        s.user("u1", "ok stop")
        s.textFinal("m1", "stopped")
        s.turn()
        assertEquivalent(s.events)
    }

    @Test func rawApprovalErrorAndRateLimitRows() {
        let s = Stream()
        s.started()
        s.user("u0", "go")
        s.raw("unknown native payload")
        s.approval("Bash")
        s.add(.approvalResolved(ApprovalResolved(
            id: ApprovalID(rawValue: "appr-1"), decision: .allow(),
            decidedBy: "user", decidedAt: Date(timeIntervalSince1970: 1_750_000_000))))
        s.error("transient API error")
        s.add(.rateLimit(RateLimit(status: "allowed_warning")))
        s.textFinal("m0", "done anyway")
        s.usage()
        s.turn()
        s.user("u1", "…")
        s.textFinal("m1", "ok")
        s.turn()
        assertEquivalent(s.events, showRaw: true)
        assertEquivalent(s.events, showRaw: false)
    }

    /// One long autonomous turn: no turn boundary for hundreds of events. Intra-turn sealing
    /// must still advance the watermark (messages close as the assistant moves on).
    @Test func intraTurnSealingInOneLongTurn() {
        let s = Stream()
        s.started()
        s.user("u0", "autonomous run")
        for i in 0..<30 {
            s.thinkingFinal("th\(i)", "step \(i)")
            s.toolStart("t\(i)", "Bash", .object(["command": .string("step \(i)")]))
            s.toolComplete("t\(i)", "Bash", .object(["command": .string("step \(i)")]))
            s.result("t\(i)")
            s.textFinal("m\(i)", "step \(i) done")
        }
        // Still mid-turn — no turnCompleted yet.
        let projector = assertEquivalent(s.events)
        #expect(projector.sealedEventCountForTesting > s.events.count / 2,
                "intra-turn sealing should keep the live tail small inside one long turn")
    }

    @Test func noSessionStartedNeverSealsButStaysEquivalent() {
        let s = Stream()
        s.user("u0", "no start event")
        s.textFinal("m0", "odd stream")
        s.turn()
        s.user("u1", "…")
        s.textFinal("m1", "ok")
        s.turn()
        // No worktree root → the projector must refuse to seal (a late root would retroactively
        // change lock folds) while remaining equivalent.
        let projector = assertEquivalent(s.events, expectSealing: false)
        #expect(projector.sealedEventCountForTesting == 0)
    }

    @Test func streamSwitchResets() {
        let a = Stream()
        a.started()
        a.user("u0", "stream A")
        a.textFinal("m0", "hello from A")
        a.turn()
        a.user("u1", "…")
        a.textFinal("m1", "more A")
        a.turn()

        let b = Stream(startSeq: 5000)  // different firstSeq → new identity
        b.started(cwd: "/other/wt")
        b.user("v0", "stream B")
        b.textFinal("n0", "hello from B")
        b.turn()

        let incremental = IncrementalTranscriptProjection()
        for p in 0...a.events.count {
            let prefix = Array(a.events.prefix(p))
            #expect(incremental.items(for: prefix, showRaw: false, showLockEvents: true)
                    == TranscriptProjection.build(prefix, showRaw: false, showLockEvents: true))
        }
        // Switch sessions on the same instance (the open reset: cleared, then refilled).
        #expect(incremental.items(for: [], showRaw: false, showLockEvents: true).isEmpty)
        for p in 0...b.events.count {
            let prefix = Array(b.events.prefix(p))
            #expect(incremental.items(for: prefix, showRaw: false, showLockEvents: true)
                    == TranscriptProjection.build(prefix, showRaw: false, showLockEvents: true))
        }
    }

    @Test func toggleFlipResets() {
        let s = Stream()
        s.started()
        s.user("u0", "go")
        s.textFinal("m0", "working")
        s.raw("hidden by default")
        s.note("Lock acquired on src/a.swift.", lockEvent: true,
               lock: NoteLock(state: .acquired, paths: ["src/a.swift"]))
        s.turn()
        s.user("u1", "…")
        s.textFinal("m1", "done")
        s.turn()

        let incremental = IncrementalTranscriptProjection()
        _ = incremental.items(for: s.events, showRaw: false, showLockEvents: true)
        // Flip both toggles against the fully-sealed stream: must refold, not serve stale rows.
        let flipped = incremental.items(for: s.events, showRaw: true, showLockEvents: false)
        #expect(flipped == TranscriptProjection.build(s.events, showRaw: true, showLockEvents: false))
    }

    /// A backfill merge can rewrite history *under* the watermark (events slot in by seq). The
    /// projector must notice the shifted prefix and reset rather than serve stale sealed items.
    @Test func backfillUnderWatermarkResets() {
        let s = Stream()
        s.started()
        s.user("u2", "late half")   // seqs 1, 2, …
        s.textFinal("m2", "tail history")
        s.turn()
        s.user("u3", "current")
        s.textFinal("m3", "working")
        s.turn()

        let incremental = IncrementalTranscriptProjection()
        _ = incremental.items(for: s.events, showRaw: false, showLockEvents: true)

        // A full-history backfill arrives: older events (lower seq) merge in above the tail.
        let older = Stream(startSeq: 2000)
        older.user("u1", "ancient history")
        older.textFinal("m1", "long ago")
        older.turn()
        let merged = (older.events + s.events.map { $0.withSeq($0.seq + 3000) })
            .sorted { $0.seq < $1.seq }
        let live = incremental.items(for: merged, showRaw: false, showLockEvents: true)
        #expect(live == TranscriptProjection.build(merged, showRaw: false, showLockEvents: true))
    }

    // MARK: - Fuzz

    /// Deterministic fuzz: compose random pattern blocks (turns, tool runs, subagents, locks,
    /// interrupts, empty thinking) and assert the property on every prefix, both toggle sets.
    @Test(arguments: [UInt64(1), 7, 42, 1234, 99999])
    func fuzzedStreams(seed: UInt64) {
        var rng = SplitMix64(seed: seed)
        let s = Stream()
        s.started()
        var msg = 0, tool = 0, editPaths: [String] = []

        for turnIndex in 0..<Int(rng.next(in: 3...6)) {
            s.user("u\(turnIndex)", "turn \(turnIndex)")
            for _ in 0..<Int(rng.next(in: 1...6)) {
                switch rng.next(in: 0...9) {
                case 0...2:  // streamed message
                    let id = "m\(msg)"; msg += 1
                    for _ in 0..<Int(rng.next(in: 1...3)) { s.textDelta(id, "word ") }
                    if rng.next(in: 0...1) == 0 { s.textFinal(id, "final text \(id)") }
                case 3:      // thinking (sometimes redacted-empty)
                    let id = "th\(msg)"; msg += 1
                    if rng.next(in: 0...3) == 0 { s.thinkingFinal(id, "") }
                    else { s.thinkingDelta(id, "hmm"); s.thinkingFinal(id, "hmm indeed") }
                case 4...5:  // tool run
                    for _ in 0..<Int(rng.next(in: 1...3)) {
                        let id = "t\(tool)"; tool += 1
                        s.toolStart(id, "Bash", .object(["command": .string("cmd \(id)")]))
                        s.toolComplete(id, "Bash", .object(["command": .string("cmd \(id)")]))
                        if rng.next(in: 0...5) > 0 { s.result(id) }  // occasionally unfinished
                    }
                case 6:      // edit + lock chatter
                    let path = "src/f\(tool).swift"
                    let id = "e\(tool)"; tool += 1
                    if rng.next(in: 0...1) == 0 {
                        s.note("Waiting for a lock on \(path)…", lockEvent: true,
                               lock: NoteLock(state: .waiting, paths: [path]))
                    }
                    s.edit(id, path: "/repo/wt/\(path)")
                    s.note("Lock acquired on \(path).", lockEvent: true,
                           lock: NoteLock(state: .acquired, paths: [path]))
                    editPaths.append(path)
                case 7:      // subagent
                    let id = "task\(tool)"; tool += 1
                    s.toolStart(id, "Task"); s.toolComplete(id, "Task")
                    s.textFinal("c\(msg)", "child prose", parent: id); msg += 1
                    let child = "ct\(tool)"; tool += 1
                    s.toolStart(child, "Read", parent: id)
                    s.toolComplete(child, "Read", parent: id)
                    s.result(child)
                    s.result(id, .string("subagent done"))
                case 8:      // a long-held lock releases against some sealed edit
                    if let path = editPaths.randomElement(using: &rng.generator) {
                        s.note("Lock released on \(path).", lockEvent: true,
                               lock: NoteLock(state: .released, paths: [path]))
                    }
                default:     // ambient rows
                    if rng.next(in: 0...1) == 0 { s.usage() } else { s.note("nvrsion: landed edit.") }
                }
            }
            if rng.next(in: 0...7) == 0 { s.finished(.interrupted) }
            s.turn()
        }
        assertEquivalent(s.events, expectSealing: true)
        assertEquivalent(s.events, showRaw: true, showLockEvents: false, expectSealing: true)
    }

    @Test func emptyThinkingIsDroppedRatherThanTrailingTheBlock() {
        // A long tool run interleaved with redacted (empty) thinking: each empty block is a
        // distinct message, so each used to survive as its own item, held aside by the coalescer
        // and re-emitted *after* the block. Those rows draw nothing, but each still claimed one
        // 14pt gap in the transcript's spaced VStack — a long Bash run stacked them into a large
        // band of blank space below the card. They must not reach the view at all.
        let s = Stream()
        s.started()
        for i in 0..<10 {
            s.toolComplete("t\(i)", "Bash", .object(["command": .string("swift build")]))
            s.result("t\(i)")
            s.thinkingFinal("m\(i)", "")
        }
        let items = TranscriptProjection.build(s.events, showRaw: false, showLockEvents: true)
        #expect(!items.contains { $0.isEmptyThinking })
        // The ten calls read as exactly one card (plus the `sessionStarted` row), nothing trails it.
        #expect(items.count == 2)
        guard case .toolBlock(let groups) = items[1].kind else {
            Issue.record("ten adjacent Bash calls must coalesce into one block"); return
        }
        #expect(groups.count == 10)
        // And the incremental projector agrees at every prefix — the drop is fold-equivalent.
        assertEquivalent(s.events)
    }

    @Test func usageRowsHiddenAndToolsGroupAcrossThem() {
        // The reported iOS bug: a run of Bash calls interleaved with per-turn token-usage rows
        // rendered as separate cards split by "↓ … ↑ …" lines. Those debug rows must be held aside
        // from the run (via `rendersNothing`) and filtered out by default, so the calls read as one
        // contiguous block — the desktop's behavior, now mirrored on the phone.
        let s = Stream()
        s.started()
        s.user("u0", "go")
        for i in 0..<5 {
            s.toolStart("t\(i)", "Bash", .object(["command": .string("cmd \(i)")]))
            s.toolComplete("t\(i)", "Bash", .object(["command": .string("cmd \(i)")]))
            s.result("t\(i)")
            s.usage()
        }

        func onlyBlock(_ items: [TranscriptItem]) -> [ToolGroup]? {
            let blocks = items.compactMap { item -> [ToolGroup]? in
                if case .toolBlock(let g) = item.kind { return g } else { return nil }
            }
            return blocks.count == 1 ? blocks[0] : nil
        }

        // Default (advanced detail off): no debug rows survive, and the five calls coalesce into
        // exactly one block instead of five cards split by usage lines.
        let plain = TranscriptProjection.build(s.events, showRaw: false, showLockEvents: true)
        #expect(!plain.contains { $0.isDebug })
        #expect(onlyBlock(plain)?.count == 5)

        // Advanced detail on: the usage rows come back (re-emitted after the block, never
        // interleaved into it), so the block still reads as one group.
        let advanced = TranscriptProjection.build(s.events, showRaw: true, showLockEvents: true)
        #expect(advanced.contains { $0.isDebug })
        #expect(onlyBlock(advanced)?.count == 5)

        // The incremental projector agrees with the one-shot fold at every prefix, both toggles.
        assertEquivalent(s.events)
        assertEquivalent(s.events, showRaw: true)
    }

    @Test func streamingThinkingSurvivesItsEmptyFirstDelta() {
        // The drop is judged on accumulated text, not the first (empty) delta — otherwise a
        // thinking block would vanish for a frame and pop back in as its text streams.
        let s = Stream()
        s.started()
        s.thinkingDelta("m1", "")
        s.thinkingDelta("m1", "pondering")
        let items = TranscriptProjection.build(s.events, showRaw: false, showLockEvents: true)
        #expect(items.count == 2)
        guard case .thinking(let text) = items[1].kind else {
            Issue.record("a thinking block that streamed real text must survive"); return
        }
        #expect(text == "pondering")
        assertEquivalent(s.events, expectSealing: false)
    }
}

/// Small deterministic RNG so fuzz failures reproduce exactly.
struct SplitMix64 {
    struct Generator: RandomNumberGenerator {
        var state: UInt64
        mutating func next() -> UInt64 {
            state &+= 0x9E37_79B9_7F4A_7C15
            var z = state
            z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
            z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
            return z ^ (z >> 31)
        }
    }
    var generator: Generator
    init(seed: UInt64) { generator = Generator(state: seed) }
    mutating func next(in range: ClosedRange<UInt64>) -> UInt64 {
        UInt64.random(in: range, using: &generator)
    }
}
