import Foundation
import NucleicProtocol

/// One render-ready row, folded from the raw `[AgentEvent]` stream. The phone subscribes at
/// `.full`, so it receives streaming text deltas and every step of a tool call's lifecycle;
/// this projection coalesces those into the same shapes the Mac transcript shows — one bubble
/// per message, one card per tool call, a single contiguous block for a run of consecutive
/// calls, and a subagent spawn carrying its own nested activity — so the view layer stays dumb.
/// (Mirrors the desktop's `TranscriptProjection` at phone fidelity, including its event
/// partitioning, tool-run coalescing, and subagent nesting.)
struct TranscriptItem: Identifiable, Equatable {
    let id: String
    /// The seq of the first event that created this item — stable scroll anchor + ordering.
    let seq: UInt64
    var kind: Kind

    enum Role: Equatable { case user, assistant }

    enum Kind: Equatable {
        case message(role: Role, text: String)
        case thinking(text: String)
        /// A single tool call. A subagent spawn (`Task`/`Agent`) carries its inner activity in
        /// `group.children` and renders as the gold subagent card; every other tool is a plain
        /// collapsible card.
        case tool(ToolGroup)
        /// A run of ≥2 consecutive tool calls coalesced into one contiguous block — the phone
        /// echo of the Mac's `.toolGroup`. A run made entirely of subagent spawns is a
        /// multi-agent fan-out (the gold orchestration card); any other run is a tool block.
        case toolBlock([ToolGroup])
        case sessionStarted(model: String, cwd: String)
        case usage(Usage)
        case rateLimit(RateLimit)
        case turnBoundary
        case approval(toolName: String)
        case runFinished(outcome: RunFinished.Outcome)
        case error(message: String)
        /// A passthrough note. `lock` carries the structured lock detail when this is a file-lock
        /// lifecycle moment, so the projection can fold it onto the edit card it brackets; `nil`
        /// for every non-lock note (and lock notes with no paths). `processStall` carries the
        /// "host command looks hung" alert payload when this note is one (or its resolution), so the
        /// row can render the amber Kill / Keep-waiting alert (control parity with the Mac); `nil`
        /// for every other note.
        case note(text: String, icon: String?, lockEvent: Bool, lock: NoteLock? = nil,
                  processStall: ProcessStallNote? = nil)
        case raw(type: String, body: String)
    }

    /// A row that draws nothing visible — an empty/redacted `.thinking` block (the agent streams
    /// a finalized empty thinking item whenever the reasoning itself is redacted). `ThinkingRow`
    /// renders no content for it, so `flatItems` drops it outright rather than carrying a phantom
    /// row into the transcript.
    ///
    /// It has to be dropped, not merely rendered as an empty view: the transcript is a
    /// `VStack(spacing: 14)` over `TranscriptRow`s, and a row whose body resolves to nothing is
    /// still a *child* of that stack — SwiftUI can't elide an opaque `View` struct the way it
    /// elides a literal `EmptyView`, so it still claims its 14pt inter-row gap. A long tool run
    /// interleaved with redacted thinking (one empty block per call) held them all aside and
    /// re-emitted them after the block, stacking one gap each into a large band of blank space
    /// between the card and the next row. Mirrors the desktop projection's `isEmptyThinking`.
    var isEmptyThinking: Bool {
        if case .thinking(let text) = kind {
            return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        }
        return false
    }

    /// Diagnostic, debug-level rows: per-turn token tallies, rate-limit notices, turn markers, and
    /// unrecognized raw passthrough events. Hidden by default to keep the transcript clean — they're
    /// the "token counts and other lines between tool calls" that fragmented a run of cards — and
    /// surfaced only when "advanced detail" (`showRaw`) is on. Prose, thinking, tool calls,
    /// session-start, approvals, run outcome, and errors are never debug. Mirrors the Mac's
    /// `TranscriptItem.isDebug`.
    var isDebug: Bool {
        switch kind {
        case .usage, .rateLimit, .turnBoundary, .raw: return true
        default: return false
        }
    }

    /// Items that draw no visible row (once debug rows are filtered out for display). They must not
    /// break a run of tool calls when coalescing: a debug/invisible row sitting between two calls
    /// would otherwise split them into two cards with a gap. Such rows are held aside during
    /// coalescing and re-emitted after the block, so consecutive tool cards stay one contiguous
    /// group. Mirrors the Mac's `rendersNothing` (`isDebug || isEmptyThinking`).
    var rendersNothing: Bool { isDebug || isEmptyThinking }
}

/// One tool call's coalesced lifecycle: start → input deltas → complete → result → file changes.
struct ToolGroup: Equatable {
    var toolCallID: String
    var name: String
    var input: JSONValue
    var result: JSONValue?
    var isError: Bool = false
    var finished: Bool = false
    /// Paths the call touched (from `fileChange` events tagged with this tool call).
    var fileChanges: [FilePatch] = []
    /// File-lock lifecycle moments (waiting/acquired/released) folded onto this call's card from
    /// the note stream (LOCKING §4), newest last. A lock note is matched to the nearest preceding
    /// edit whose paths overlap, so the lock reads as part of the edit rather than as a
    /// free-floating row that would otherwise sort after the call. Empty when nothing locked this
    /// call, or the "Show lock events" setting is off. Mirrors the Mac's folded `lockLines`.
    var lockLines: [NoteLock] = []
    /// For a subagent spawn (`Task`/`Agent`): the inner activity it produced — its own tool
    /// calls, thinking, and prose — projected the same way and nested here, so the card can show
    /// its live Read/Bash/Edit stream. Empty for every ordinary (non-subagent) tool.
    var children: [TranscriptItem] = []

    struct FilePatch: Equatable { let path: String; let change: FileChange.ChangeKind }

    /// Nucleic's own Orchestra worker spawn — the qualified `nucleic_subagent` MCP wire name.
    /// Unlike a `Task` subagent, a worker runs as its own observed session, so it carries no
    /// nested `children` here; its card shows the final output it returned instead.
    static let orchestraSubagentToolName = "mcp__nucleic__nucleic_subagent"

    /// Subagent orchestration — a `Task`/`Agent` spawn or a `nucleic_subagent` worker — gets the
    /// gold card treatment, like the Mac.
    var isOrchestration: Bool {
        name == "Task" || name == "Agent" || name == Self.orchestraSubagentToolName
    }
    var isAskUserQuestion: Bool { name == "AskUserQuestion" }
    var isExitPlanMode: Bool { name == ExitPlanMode.toolName }

    /// The literal shell command a `Bash` call ran, if any (nil for every other tool). Used to
    /// detect a git-commit pipeline that should read as a structured commit card at the row level,
    /// exactly as the Mac promotes it out of the generic tool row.
    var bashCommand: String? {
        (name == "Bash" || name == "Shell") ? input["command"]?.stringValue : nil
    }
}

enum TranscriptProjection {
    /// How deep subagent nesting is followed before deeper descendants are left unattached — a
    /// safety bound on the recursion (a subagent spawning a subagent spawning a subagent …), far
    /// past any real fan-out depth. Mirrors the desktop projection.
    private static let maxSubagentDepth = 4

    /// Fold the raw event stream into render rows. `showRaw` surfaces unrecognized passthrough
    /// events (debug); `showLockEvents` keeps file-lock lifecycle notes (off = quieter feed).
    ///
    /// Work that happens *inside* a spawned subagent arrives in this same stream tagged with the
    /// parent `Task`'s id (`parentToolCallID`). We partition events by which subagent (if any)
    /// owns them, project the main agent's own events at the top level, and recursively project
    /// each subagent's events into the `children` of its spawn — so a subagent's inner work nests
    /// under its card instead of leaking (and interleaving) into the main transcript.
    ///
    /// One-shot form: folds the whole stream in one pass. The live transcript uses
    /// `IncrementalTranscriptProjection`, which folds the stream as seam-delimited segments via
    /// `buildSegment` — this wrapper is the `priorEdits: []` whole-stream case of that.
    static func build(_ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool) -> [TranscriptItem] {
        buildSegment(events[...], worktreeRoot: worktreeRoot(in: events[...]), priorEdits: [],
                     showRaw: showRaw, showLockEvents: showLockEvents).items
    }

    /// An edit-class tool call folded in an *earlier* segment, carried forward so a later
    /// segment's lock notes can still fold onto it across the seam (a lock's `released` note
    /// lands when the file *lands* in the parent — potentially many turns after the edit).
    struct PriorEdit: Equatable {
        let toolCallID: String
        /// Repo-relative, normalized paths this call writes (`editedPaths` ∘ `normalizeForLock`).
        let paths: [String]
    }

    /// The folded output of one contiguous slice of the stream.
    struct SegmentFold {
        var items: [TranscriptItem]
        /// Lock lines whose backward path-match crossed the seam onto a `priorEdits` entry, in
        /// note order. The caller owns those already-folded items and must attach these to them —
        /// that's what keeps `fold(prefix) ++ fold(tail)` byte-identical to `fold(whole)` even
        /// for a lock note released turns after its edit.
        var priorLockPatches: [(toolCallID: String, lock: NoteLock)]
        /// This segment's own edit-class calls (in item order), for the caller's registry.
        var edits: [PriorEdit]
    }

    /// Fold one contiguous slice of the stream with an injected worktree root (a mid-stream
    /// slice lacks the seq-0 `sessionStarted` that `build` rescans for). The slice must be
    /// seam-delimited — no coalescing key, subagent scope, or open tool run straddling either
    /// end — which is exactly what `IncrementalTranscriptProjection` guarantees before calling.
    static func buildSegment(
        _ events: ArraySlice<AgentEvent>, worktreeRoot root: String?, priorEdits: [PriorEdit],
        showRaw: Bool, showLockEvents: Bool
    ) -> SegmentFold {
        let (topLevel, byParent) = partition(events)
        // Fold lock-lifecycle notes onto the edit cards they bracket, exactly as the Mac's
        // `items(_:worktreeRoot:)` does — matched against the session's working directory so an
        // edit's absolute `file_path` compares against the note's repo-relative paths. Folding
        // happens only at the top level (a subagent's inner edits are literal, unlocked); the
        // subagent recursion below stays plain, matching the desktop projection.
        var patches: [(toolCallID: String, lock: NoteLock)] = []
        let flat = foldLockNotes(flatItems(topLevel, showRaw: showRaw, showLockEvents: showLockEvents),
                                 worktreeRoot: root, priorEdits: priorEdits, priorLockPatches: &patches)
        let coalesced = coalesceToolRuns(flat).map {
            attachSubagentChildren($0, byParent: byParent, depth: 0,
                                   showRaw: showRaw, showLockEvents: showLockEvents)
        }
        // Drop the diagnostic rows (token tallies, rate-limit notices, turn markers, raw
        // passthrough) unless "advanced detail" is on. `coalesceToolRuns` already held them aside
        // from any tool run (they're `rendersNothing`), so removing them here leaves consecutive
        // tool cards as one contiguous block instead of a stack fragmented by token-count lines.
        // Filtering this shared choke point — rather than the view — keeps the incremental
        // projector's `fold(prefix) ++ fold(tail)` byte-identical to `fold(whole)`: a debug row is
        // never a tool edit or lock target, so the filter distributes cleanly over the seam and the
        // per-segment `edits`/`priorLockPatches` bookkeeping below is unaffected.
        let items = showRaw ? coalesced : coalesced.filter { !$0.isDebug }
        let edits = flat.compactMap { item -> PriorEdit? in
            guard case .tool(let group) = item.kind else { return nil }
            let paths = editedPaths(toolName: group.name, input: group.input)
                .map { normalizeForLock($0, worktreeRoot: root) }
                .filter { !$0.isEmpty }
            return paths.isEmpty ? nil : PriorEdit(toolCallID: group.toolCallID, paths: paths)
        }
        return SegmentFold(items: items, priorLockPatches: patches, edits: edits)
    }

    /// The session's working directory, read from its `sessionStarted` event, so an edit's
    /// absolute `file_path` can be made repo-relative to compare against a lock note's
    /// repo-relative paths. `nil` before the start event is seen (nothing to fold against yet).
    static func worktreeRoot(in events: ArraySlice<AgentEvent>) -> String? {
        for event in events {
            if case .sessionStarted(let started) = event.kind, !started.cwd.isEmpty {
                return started.cwd
            }
        }
        return nil
    }

    // MARK: - Lock-note folding (LOCKING §4)

    /// Match each lock-lifecycle note in `flat` to the nearest preceding tool item whose edited
    /// paths overlap, attaching the matched `NoteLock`s to that call's `ToolGroup.lockLines` and
    /// dropping the note from the list. Overlap is directory-aware (`pathsOverlap`); a tool call's
    /// `file_path` is stripped of `worktreeRoot` and normalized so it compares against the note's
    /// repo-relative paths. A note overlapping no preceding edit is left in place (renders
    /// standalone), matching the Mac's `foldLockNotes`.
    ///
    /// The backward scan continues past the front of `flat` into `priorEdits` — the edits of
    /// already-folded earlier segments, oldest first — so a note whose edit was sealed in a prior
    /// segment (a lock released turns later) still folds exactly where a whole-stream fold would
    /// put it. Those hits are reported via `priorLockPatches` for the caller to attach.
    private static func foldLockNotes(
        _ flat: [TranscriptItem], worktreeRoot: String?, priorEdits: [PriorEdit],
        priorLockPatches: inout [(toolCallID: String, lock: NoteLock)]
    ) -> [TranscriptItem] {
        // Each tool item's repo-relative edited paths (only edit-class tools have any), by index.
        var editsByIndex: [Int: (id: String, paths: [String])] = [:]
        for (i, item) in flat.enumerated() {
            guard case .tool(let group) = item.kind else { continue }
            let paths = editedPaths(toolName: group.name, input: group.input)
                .map { normalizeForLock($0, worktreeRoot: worktreeRoot) }
                .filter { !$0.isEmpty }
            if !paths.isEmpty { editsByIndex[i] = (group.toolCallID, paths) }
        }
        guard !editsByIndex.isEmpty || !priorEdits.isEmpty else { return flat }
        let priorIDs = Set(priorEdits.map(\.toolCallID))

        var locksByCall: [String: [NoteLock]] = [:]
        var folded = Set<Int>()
        for (i, item) in flat.enumerated() {
            guard case .note(_, _, true, let noteLock, _) = item.kind,
                  let lock = noteLock, !lock.paths.isEmpty else { continue }
            // Route each path to the nearest preceding edit card that touches it, so a multi-file
            // note brackets each file's own card. Fold only when *every* path lands on a card; a
            // partial match stays a single standalone row rather than splitting across cards.
            var perCard: [(id: String, paths: [String])] = []
            var indexByID: [String: Int] = [:]
            var matchedAll = true
            for path in lock.paths {
                var hitID: String?
                for j in stride(from: i - 1, through: 0, by: -1) {
                    guard let edit = editsByIndex[j] else { continue }
                    if edit.paths.contains(where: { pathsOverlap($0, path) }) { hitID = edit.id; break }
                }
                if hitID == nil {
                    // Nothing in this segment — keep scanning backward across the seam, newest
                    // prior edit first, exactly where a whole-stream scan would look next.
                    for prior in priorEdits.reversed()
                    where prior.paths.contains(where: { pathsOverlap($0, path) }) {
                        hitID = prior.toolCallID
                        break
                    }
                }
                guard let hitID else { matchedAll = false; break }
                if let k = indexByID[hitID] { perCard[k].paths.append(path) }
                else { indexByID[hitID] = perCard.count; perCard.append((hitID, [path])) }
            }
            guard matchedAll, !perCard.isEmpty else { continue }
            for card in perCard {
                if priorIDs.contains(card.id) {
                    priorLockPatches.append((card.id, NoteLock(state: lock.state, paths: card.paths)))
                } else {
                    locksByCall[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths))
                }
            }
            folded.insert(i)
        }
        guard !folded.isEmpty else { return flat }

        return flat.enumerated().compactMap { i, item in
            if folded.contains(i) { return nil }
            guard case .tool(var group) = item.kind, let locks = locksByCall[group.toolCallID] else { return item }
            group.lockLines = locks
            return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(group))
        }
    }

    /// The paths an edit-class tool writes (mirrors `RiskClassifier.editedPaths`). Only Edit /
    /// Write / MultiEdit / NotebookEdit carry a lockable path; everything else has none.
    private static func editedPaths(toolName: String, input: JSONValue) -> [String] {
        switch toolName {
        case "Edit", "Write", "MultiEdit":
            return [input["file_path"]?.stringValue].compactMap { $0 }
        case "NotebookEdit":
            return [input["notebook_path"]?.stringValue].compactMap { $0 }
        default:
            return []
        }
    }

    /// Make a raw tool-call path comparable to a lock note's repo-relative path: strip the worktree
    /// root prefix and normalize. A path not under the root is left as-is (it simply won't overlap).
    /// Mirrors the Mac's `normalizeForLock`.
    private static func normalizeForLock(_ path: String, worktreeRoot: String?) -> String {
        var p = path
        if let root = worktreeRoot, !root.isEmpty, p.hasPrefix(root) { p = String(p.dropFirst(root.count)) }
        return normalizePath(p)
    }

    /// Trim `./`, surrounding slashes, and whitespace to a bare repo-relative path
    /// (mirrors `ConflictDetector.normalize`).
    private static func normalizePath(_ path: String) -> String {
        var p = path.trimmingCharacters(in: .whitespaces)
        while p.hasPrefix("./") { p.removeFirst(2) }
        while p.hasPrefix("/") { p.removeFirst() }
        while p.hasSuffix("/") { p.removeLast() }
        return p
    }

    /// True when two repo-relative paths refer to the same file or one is a directory ancestor of
    /// the other — compared componentwise so `src` never matches `src2/x` (mirrors
    /// `ConflictDetector.pathsOverlap`).
    private static func pathsOverlap(_ a: String, _ b: String) -> Bool {
        guard !a.isEmpty, !b.isEmpty else { return false }
        let ca = a.split(separator: "/")
        let cb = b.split(separator: "/")
        let n = min(ca.count, cb.count)
        for i in 0..<n where ca[i] != cb[i] { return false }
        return true
    }

    // MARK: - Subagent partitioning

    /// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
    /// the spawning `Task`'s id.
    private static func partition(_ events: ArraySlice<AgentEvent>)
        -> (topLevel: [AgentEvent], byParent: [String: [AgentEvent]])
    {
        let parentOf = toolParentMap(events)
        var topLevel: [AgentEvent] = []
        var byParent: [String: [AgentEvent]] = [:]
        for event in events {
            if let owner = subagentOwner(of: event, parentOf: parentOf) {
                byParent[owner, default: []].append(event)
            } else {
                topLevel.append(event)
            }
        }
        return (topLevel, byParent)
    }

    /// Maps each tool-call id to its parent subagent's id, built from the call start/complete
    /// events. A tool *result* or *file change* names only a tool id, so it inherits its subagent
    /// scope from the call it belongs to via this map.
    private static func toolParentMap(_ events: ArraySlice<AgentEvent>) -> [String: String] {
        var map: [String: String] = [:]
        for event in events {
            switch event.kind {
            case .toolCallStarted(let call), .toolCallCompleted(let call):
                if let parent = call.parentToolCallID { map[call.toolCallID] = parent }
            default:
                break
            }
        }
        return map
    }

    /// The id of the subagent whose scope this event belongs to, or nil for the main agent's own
    /// turn. Tool calls and the subagent's prose/thinking carry the parent link directly; a tool
    /// result or file change inherits it from the call it references.
    private static func subagentOwner(of event: AgentEvent, parentOf: [String: String]) -> String? {
        switch event.kind {
        case .toolCallStarted(let call), .toolCallCompleted(let call):
            return call.parentToolCallID
        case .toolResult(let result):
            return parentOf[result.toolCallID]
        case .fileChange(let change):
            return change.toolCallID.flatMap { parentOf[$0] }
        case .assistantText(let chunk), .thinking(let chunk):
            return chunk.parentToolCallID
        case .note(let note):
            // A lock/nvrsion note tagged with the edit it brackets inherits that edit's subagent
            // scope — like a `.toolResult`/`.fileChange` — so a subagent's lock-acquire or
            // nvrsion-land note nests in its card instead of leaking to the main transcript. An
            // untagged note (a turn-end release, a user git note) has no owner.
            return note.toolCallID.flatMap { parentOf[$0] }
        default:
            return nil
        }
    }

    /// Projects one scope's events into items, coalesces runs of tool calls, then nests each
    /// subagent spawn's own activity (drawn from `byParent`) as its `children`, recursing for
    /// subagents-within-subagents up to `maxSubagentDepth`.
    private static func project(
        _ events: [AgentEvent], byParent: [String: [AgentEvent]], depth: Int,
        showRaw: Bool, showLockEvents: Bool
    ) -> [TranscriptItem] {
        let items = coalesceToolRuns(flatItems(events, showRaw: showRaw, showLockEvents: showLockEvents))
        guard depth < maxSubagentDepth else { return items }
        return items.map {
            attachSubagentChildren($0, byParent: byParent, depth: depth,
                                   showRaw: showRaw, showLockEvents: showLockEvents)
        }
    }

    /// For a subagent spawn (`Task`/`Agent`) — lone or inside a fan-out block — projects the
    /// events it owns into its `children`. Leaves every ordinary tool untouched.
    private static func attachSubagentChildren(
        _ item: TranscriptItem, byParent: [String: [AgentEvent]], depth: Int,
        showRaw: Bool, showLockEvents: Bool
    ) -> TranscriptItem {
        func childrenFor(_ group: ToolGroup) -> ToolGroup {
            guard group.isOrchestration else { return group }
            var g = group
            g.children = project(byParent[group.toolCallID] ?? [], byParent: byParent, depth: depth + 1,
                                 showRaw: showRaw, showLockEvents: showLockEvents)
            return g
        }
        switch item.kind {
        case .tool(let group):
            guard group.isOrchestration else { return item }
            return TranscriptItem(id: item.id, seq: item.seq, kind: .tool(childrenFor(group)))
        case .toolBlock(let groups):
            return TranscriptItem(id: item.id, seq: item.seq, kind: .toolBlock(groups.map(childrenFor)))
        default:
            return item
        }
    }

    // MARK: - Tool-run coalescing

    /// Collapses maximal runs of adjacent `.tool` items (length ≥2) into one `.toolBlock`.
    ///
    /// Items that draw nothing (see `rendersNothing`) never break a run: an invisible row landing
    /// between two tool calls must not split them into two cards with a gap. Such items are held
    /// aside and re-emitted right after the block, so the calls stay one contiguous block.
    /// Mirrors the desktop projection.
    ///
    /// A subagent spawn (`Task`/`Agent`) only groups with *other* subagent spawns: a run of them
    /// is a multi-agent fan-out (the gold orchestration card), which the view gates on the block
    /// being all-orchestration. So a subagent-ness switch breaks the run — a wave of `Task`s
    /// followed by the agent's own `Bash` calls yields a fan-out card *and* a tool block, not one
    /// generic block titled by the first `Task` that would swallow the fan-out.
    ///
    /// `flatItems` already drops the only kind the phone has (empty redacted thinking), so the
    /// hold path is a guard for a caller passing unfiltered items — not a live case.
    static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
        var out: [TranscriptItem] = []
        var run: [TranscriptItem] = []          // consecutive `.tool` items
        var held: [TranscriptItem] = []         // non-rendering rows seen mid-run, held so they don't split it

        func flush() {
            if run.count == 1 {
                out.append(run[0])
            } else if run.count >= 2 {
                let groups = run.compactMap { item -> ToolGroup? in
                    if case .tool(let g) = item.kind { return g } else { return nil }
                }
                out.append(TranscriptItem(id: "toolblock-\(groups[0].toolCallID)",
                                          seq: run[0].seq, kind: .toolBlock(groups)))
            }
            run.removeAll(keepingCapacity: true)
            out.append(contentsOf: held)
            held.removeAll(keepingCapacity: true)
        }

        for item in flat {
            if case .tool(let group) = item.kind {
                // A subagent spawn and an ordinary tool call never share a run (see above):
                // switching kind flushes the run in progress so each side forms its own card.
                // `isOrchestration` mirrors the view's fan-out gate (`groups.allSatisfy(\.isOrchestration)`)
                // exactly — worker spawns included — so no mixed block can ever reach it.
                if case .tool(let first)? = run.first?.kind, first.isOrchestration != group.isOrchestration {
                    flush()
                }
                run.append(item)
            } else if item.rendersNothing, !run.isEmpty {
                held.append(item)
            } else {
                flush()
                out.append(item)
            }
        }
        flush()
        return out
    }

    // MARK: - Flat projection (one scope)

    /// Fold one scope's raw events into interleaved, display-ready rows: streaming assistant /
    /// thinking deltas accumulate into one growing row, and a tool call shows the moment it starts
    /// (`toolCallStarted`), with its full input/result/file-changes filled in as they arrive.
    private static func flatItems(
        _ events: [AgentEvent], showRaw: Bool, showLockEvents: Bool
    ) -> [TranscriptItem] {
        var items: [TranscriptItem] = []
        var messageIndex: [String: Int] = [:]   // messageID → items index (text coalescing)
        var thinkingIndex: [String: Int] = [:]
        var toolIndex: [String: Int] = [:]       // toolCallID → items index

        for event in events {
            switch event.kind {
            case .userText(let chunk):
                coalesceText(.user, chunk, seq: event.seq, into: &items, index: &messageIndex)
            case .assistantText(let chunk):
                coalesceText(.assistant, chunk, seq: event.seq, into: &items, index: &messageIndex)
            case .thinking(let chunk):
                coalesceThinking(chunk, seq: event.seq, into: &items, index: &thinkingIndex)

            case .toolCallStarted(let call), .toolCallCompleted(let call):
                let finished: Bool = { if case .toolCallCompleted = event.kind { return true } else { return false } }()
                upsertTool(call, finished: finished, seq: event.seq, into: &items, index: &toolIndex)
            case .toolCallInputDelta:
                break  // the final `toolCallCompleted` carries the assembled input
            case .toolResult(let result):
                attachResult(result, into: &items, index: toolIndex)
            case .fileChange(let change):
                attachFileChange(change, seq: event.seq, into: &items, index: toolIndex)

            case .sessionStarted(let started):
                items.append(.init(id: "start-\(event.seq)", seq: event.seq,
                                   kind: .sessionStarted(model: started.model, cwd: started.cwd)))
            case .usage(let usage):
                items.append(.init(id: "usage-\(event.seq)", seq: event.seq, kind: .usage(usage)))
            case .rateLimit(let limit):
                items.append(.init(id: "rate-\(event.seq)", seq: event.seq, kind: .rateLimit(limit)))
            case .codexUsage:
                break  // account-wide quota telemetry, folded into the dashboard — not the transcript
            case .turnCompleted:
                items.append(.init(id: "turn-\(event.seq)", seq: event.seq, kind: .turnBoundary))
            case .approvalRequested(let req):
                items.append(.init(id: "appr-\(event.seq)", seq: event.seq, kind: .approval(toolName: req.toolName)))
            case .approvalResolved:
                break  // dismissal is reflected in the live approval card, not the transcript
            case .runFinished(let finished):
                items.append(.init(id: "fin-\(event.seq)", seq: event.seq,
                                   kind: .runFinished(outcome: finished.outcome)))
            case .error(let err):
                items.append(.init(id: "err-\(event.seq)", seq: event.seq, kind: .error(message: err.message)))
            case .note(let note):
                if note.lockEvent && !showLockEvents { break }
                items.append(.init(id: "note-\(event.seq)", seq: event.seq,
                                   kind: .note(text: note.text, icon: note.icon,
                                               lockEvent: note.lockEvent, lock: note.lock,
                                               processStall: note.processStall)))
            case .raw(let raw):
                guard showRaw else { break }
                items.append(.init(id: "raw-\(event.seq)", seq: event.seq,
                                   kind: .raw(type: event.nativeType ?? "raw", body: raw.native.compactSummary)))
            }
        }
        // A redacted (empty) thinking block draws no row but would still cost one inter-row gap
        // in the transcript's spaced VStack — see `isEmptyThinking`. Drop it here, after the
        // coalescing upserts, so a *streaming* thinking item is judged on its accumulated text
        // rather than on the empty first delta. A thinking message never straddles a segment
        // seam (`buildSegment`'s precondition), so this stays fold-equivalent per segment.
        return items.filter { !$0.isEmptyThinking }
    }

    // MARK: - Coalescing helpers

    private static func coalesceText(
        _ role: TranscriptItem.Role, _ chunk: TextChunk, seq: UInt64,
        into items: inout [TranscriptItem], index: inout [String: Int]
    ) {
        if let i = index[chunk.messageID], case .message(let r, let existing) = items[i].kind {
            // A non-partial chunk is the authoritative full text; partials accumulate.
            let text = chunk.isPartial ? existing + chunk.text : chunk.text
            items[i].kind = .message(role: r, text: text)
        } else {
            index[chunk.messageID] = items.count
            items.append(.init(id: "msg-\(chunk.messageID)", seq: seq,
                               kind: .message(role: role, text: chunk.text)))
        }
    }

    private static func coalesceThinking(
        _ chunk: TextChunk, seq: UInt64, into items: inout [TranscriptItem], index: inout [String: Int]
    ) {
        if let i = index[chunk.messageID], case .thinking(let existing) = items[i].kind {
            items[i].kind = .thinking(text: chunk.isPartial ? existing + chunk.text : chunk.text)
        } else {
            index[chunk.messageID] = items.count
            items.append(.init(id: "think-\(chunk.messageID)", seq: seq, kind: .thinking(text: chunk.text)))
        }
    }

    private static func upsertTool(
        _ call: ToolCall, finished: Bool, seq: UInt64,
        into items: inout [TranscriptItem], index: inout [String: Int]
    ) {
        if let i = index[call.toolCallID], var group = currentGroup(items[i]) {
            group.name = call.name
            if !call.input.isEmptyValue { group.input = call.input }
            group.finished = group.finished || finished
            items[i].kind = .tool(group)
        } else {
            index[call.toolCallID] = items.count
            let group = ToolGroup(toolCallID: call.toolCallID, name: call.name, input: call.input, finished: finished)
            items.append(.init(id: "tool-\(call.toolCallID)", seq: seq, kind: .tool(group)))
        }
    }

    private static func attachResult(
        _ result: ToolResult, into items: inout [TranscriptItem], index: [String: Int]
    ) {
        guard let i = index[result.toolCallID], var group = currentGroup(items[i]) else { return }
        group.result = result.content
        group.isError = result.isError
        group.finished = true
        items[i].kind = .tool(group)
    }

    private static func attachFileChange(
        _ change: FileChange, seq: UInt64, into items: inout [TranscriptItem], index: [String: Int]
    ) {
        if let id = change.toolCallID, let i = index[id], var group = currentGroup(items[i]) {
            group.fileChanges.append(.init(path: change.path, change: change.kind))
            items[i].kind = .tool(group)
        }
        // Untagged file changes are folded into the diff stat, not the transcript.
    }

    /// Extract a tool group from a `.tool` item (the only kind `flatItems` produces for a call;
    /// blocks/children are formed later, after coalescing).
    private static func currentGroup(_ item: TranscriptItem) -> ToolGroup? {
        if case .tool(let g) = item.kind { return g }
        return nil
    }
}

extension JSONValue {
    /// Whether this value carries nothing worth keeping (so a later, fuller input wins).
    var isEmptyValue: Bool {
        switch self {
        case .null: return true
        case .object(let o): return o.isEmpty
        case .string(let s): return s.isEmpty
        default: return false
        }
    }

    /// A one-line gist of a tool input/result for the collapsed row. Lives here (not with the
    /// row views) because the projection folds it into `.raw` bodies, and this file also builds
    /// standalone as the host-testable `NucleicRemoteProjection` SwiftPM target.
    var compactSummary: String {
        switch self {
        case .string(let s): return s
        case .object(let o):
            if let cmd = o["command"]?.stringValue { return cmd }
            if let path = o["file_path"]?.stringValue ?? o["path"]?.stringValue { return path }
            return o.keys.sorted().joined(separator: ", ")
        case .array(let a): return "[\(a.count) items]"
        case .number(let n): return String(n)
        case .bool(let b): return String(b)
        case .null: return "null"
        }
    }
}
