import Foundation

/// A coalesced, display-ready transcript row. Streaming deltas (assistant text,
/// thinking, and tool calls) are merged by message / tool id into a single
/// live-updating item, so they render as the model produces them rather than only
/// once the whole message is finalized.
///
/// Pure data (no SwiftUI), so it lives in Core: the view renders it, and the store
/// reuses the same coalescing to enumerate tool-call groups for AFM summarization —
/// one source of truth, so the group boundaries and signatures always agree.
public enum TranscriptItem: Identifiable {
    /// A pass-through event rendered as-is (everything that isn't streamed prose).
    case event(AgentEvent)
    case assistant(id: String, text: String, partial: Bool)
    case thinking(id: String, text: String, partial: Bool)
    /// A single tool call. `children` is the subagent's own activity — the calls, thinking,
    /// and prose it produced — 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.
    case tool(id: String, call: ToolCall, result: ToolResult?, finished: Bool, children: [TranscriptItem])
    /// A run of ≥2 consecutive tool calls coalesced into one contiguous block. The
    /// `signature` content-addresses the calls (the AppStore's AFM-summary cache key);
    /// `id` is stable across streaming since runs only ever append.
    case toolGroup(id: String, signature: Int, calls: [ToolEntry])
    /// A live "thinking tokens" meter: Claude's `system/thinking_tokens` progress pings —
    /// each a cumulative token estimate (`totalTokens`) plus the per-ping change
    /// (`deltaTokens`) — coalesced into one row that updates as the model reasons, with the
    /// `elapsed` time since the run's first ping. Debug-level: shown only with "advanced
    /// detail" on (the pings arrive only then). `id` is stable across the run so it updates
    /// in place rather than re-appearing.
    case thinkingTokens(id: String, totalTokens: Int, deltaTokens: Int, elapsed: TimeInterval)

    public var id: String {
        switch self {
        case .event(let e): return "seq-\(e.seq)"
        case .assistant(let id, _, _): return "assist-\(id)"
        case .thinking(let id, _, _): return "think-\(id)"
        case .tool(let id, _, _, _, _): return "tool-\(id)"
        case .toolGroup(let id, _, _): return id
        case .thinkingTokens(let id, _, _, _): return id
        }
    }

    /// Diagnostic, debug-level rows: rate-limit notices, token-usage tallies, turn
    /// markers, and unrecognized raw passthrough events. Hidden by default to keep
    /// the transcript clean; shown when "advanced detail" is enabled. Prose,
    /// thinking, tool calls, results, file changes, approvals, and errors are never
    /// treated as debug.
    public var isDebug: Bool {
        switch self {
        // The thinking-tokens meter is a diagnostic tally, surfaced only with advanced detail.
        case .thinkingTokens: return true
        case .event(let event):
            switch event.kind {
            case .usage, .rateLimit, .turnCompleted, .raw: return true
            default: return false
            }
        default: return false
        }
    }

    /// A file-lock lifecycle note (waiting/acquired/released). Hidden when the "Show lock
    /// events" setting is off, so the contention trail can be muted without losing other notes.
    public var isLockEvent: Bool {
        guard case .event(let event) = self, case .note(let note) = event.kind else { return false }
        return note.lockEvent
    }

    /// Items that draw no visible row: hidden debug events, and a `.thinking` block
    /// whose text is empty/whitespace (Claude streams a finalized empty thinking item
    /// whenever the reasoning itself is redacted — the view renders nothing for it).
    /// These must not break a run of tool calls when coalescing: an invisible row
    /// sitting between two calls would otherwise split them into two cards with a gap.
    var rendersNothing: Bool {
        if isDebug { return true }
        if case .thinking(_, let text, _) = self {
            return text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
        }
        return false
    }
}

/// One tool call within a coalesced group (or a subagent's child list): the call, its result,
/// whether it has finished, and — for a subagent spawn — the nested activity it produced.
public typealias ToolEntry = (call: ToolCall, result: ToolResult?, finished: Bool, children: [TranscriptItem])

/// Collapses the raw canonical event stream into live, display-ready items: partial
/// assistant/thinking deltas accumulate into one growing row (replaced by the
/// finalized text when it arrives), and a tool call shows the moment it starts
/// (`toolCallStarted`), with its full input filled in on completion.
public 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.
    private static let maxSubagentDepth = 4

    /// Display items for the main conversation, with each subagent spawn carrying its own
    /// inner activity as nested `children`.
    ///
    /// Work that happens *inside* a spawned subagent arrives in this same event stream tagged
    /// with the parent `Task`'s id (Claude's `parent_tool_use_id`, Grok's `parent_id`) — tool
    /// calls, their results, inferred file changes, and the subagent's own prose/thinking. 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 Read/Bash/Edit/thinking nests under its card
    /// instead of leaking into the main transcript (and interleaving there, which would break
    /// the run of all-subagent calls that the orchestration card is built from).
    public static func items(_ events: [AgentEvent]) -> [TranscriptItem] {
        let (topLevel, byParent) = partition(events)
        return project(topLevel, byParent: byParent, depth: 0)
    }

    /// Display projection: like `items(_:)` but additionally **folds lock-lifecycle notes onto the
    /// edit's tool card they bracket** (LOCKING §4). Each lock note (`NoteEvent.lock`) is matched to
    /// the nearest *preceding* top-level edit tool call whose edited paths overlap the note's paths,
    /// and returned in `lockLines` keyed by that call's id — so the view renders the lock as part of
    /// the card rather than as a free-floating row that, because the card anchors at the tool-use
    /// *start* (before the permission-gate acquire), always sorts after the call. A note matching no
    /// card (a lock reconstructed at launch, a release whose edit isn't in view) stays a standalone
    /// row in `items`, exactly as before. `worktreeRoot` lets a tool call's absolute `file_path` be
    /// made repo-relative to compare against the note's repo-relative paths.
    public static func items(_ events: [AgentEvent], worktreeRoot: String?)
        -> (items: [TranscriptItem], lockLines: [String: [NoteLock]])
    {
        let (topLevel, byParent) = partition(events)
        let (folded, lockLines) = foldLockNotes(flatItems(topLevel), worktreeRoot: worktreeRoot)
        let items = coalesceToolRuns(folded).map { attachSubagentChildren($0, byParent: byParent, depth: 0) }
        return (items, lockLines)
    }

    /// Split a scope's events into the main agent's own (`topLevel`) and each subagent's, keyed by
    /// the spawning `Task`'s id — the partition both `items` overloads build on.
    private static func partition(_ events: [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)
    }

    /// Match each lock-lifecycle note in `flat` to the nearest preceding tool item whose edited
    /// paths overlap, returning the list with matched notes removed and a `toolCallID → [NoteLock]`
    /// map of what was folded (in transcript order). Overlap is directory-aware
    /// (`ConflictDetector.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 tool item is left untouched in the list (it renders standalone).
    private static func foldLockNotes(_ flat: [TranscriptItem], worktreeRoot: String?)
        -> (items: [TranscriptItem], lockLines: [String: [NoteLock]])
    {
        // 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 call, _, _, _) = item else { continue }
            let paths = RiskClassifier.editedPaths(toolName: call.name, input: call.input)
                .map { normalizeForLock($0, worktreeRoot: worktreeRoot) }
                .filter { !$0.isEmpty }
            if !paths.isEmpty { editsByIndex[i] = (call.toolCallID, paths) }
        }
        guard !editsByIndex.isEmpty else { return (flat, [:]) }

        var lockLines: [String: [NoteLock]] = [:]
        var folded = Set<Int>()
        for (i, item) in flat.enumerated() {
            guard case .event(let event) = item, case .note(let note) = event.kind,
                  let lock = note.lock, !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 (e.g. a path from a launch-reconstructed lock with no edit in view)
            // stays a single standalone row rather than splitting across cards and the trail.
            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: { ConflictDetector.pathsOverlap($0, path) }) {
                        hitID = edit.id; 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 {
                lockLines[card.id, default: []].append(NoteLock(state: lock.state, paths: card.paths))
            }
            folded.insert(i)
        }
        guard !folded.isEmpty else { return (flat, lockLines) }
        let remaining = flat.enumerated().filter { !folded.contains($0.offset) }.map(\.element)
        return (remaining, lockLines)
    }

    /// Make a raw tool-call path comparable to a lock note's repo-relative path: strip the worktree
    /// root prefix (as the arbiter does before locking) and normalize. A path that isn't under the
    /// root is left as-is — it simply won't overlap, so its note stays standalone.
    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 ConflictDetector.normalize(p)
    }

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

    /// For a subagent spawn (`Task`/`Agent`) — lone or inside a fan-out group — projects the
    /// events it owns into its `children`. Leaves every ordinary tool untouched.
    private static func attachSubagentChildren(
        _ item: TranscriptItem, byParent: [String: [AgentEvent]], depth: Int
    ) -> TranscriptItem {
        switch item {
        case .tool(let id, let call, let result, let finished, _):
            guard isSubagentSpawn(call) else { return item }
            let children = project(byParent[call.toolCallID] ?? [], byParent: byParent, depth: depth + 1)
            return .tool(id: id, call: call, result: result, finished: finished, children: children)
        case .toolGroup(let id, let signature, let calls):
            let nested = calls.map { entry -> ToolEntry in
                guard isSubagentSpawn(entry.call) else { return entry }
                let children = project(
                    byParent[entry.call.toolCallID] ?? [], byParent: byParent, depth: depth + 1)
                return (entry.call, entry.result, entry.finished, children)
            }
            return .toolGroup(id: id, signature: signature, calls: nested)
        default:
            return item
        }
    }

    /// Whether `call` spawns a subagent — Claude Code's `Task` tool (and its `Agent` alias).
    /// These carry their inner work as nested `children` and get the dedicated subagent /
    /// orchestration cards instead of a generic tool row.
    public static func isSubagentSpawn(_ call: ToolCall) -> Bool {
        call.name == "Task" || call.name == "Agent"
    }

    /// 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: [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
        default:
            return nil
        }
    }

    /// The main agent's own events — everything not owned by a subagent — for the store's
    /// summary cache, which only summarizes the top-level conversation (a subagent's inner
    /// calls render in its card as literal lines, not AFM-rephrased ones).
    private static func topLevelEvents(_ events: [AgentEvent]) -> [AgentEvent] {
        let parentOf = toolParentMap(events)
        return events.filter { subagentOwner(of: $0, parentOf: parentOf) == nil }
    }

    /// Coalesces maximal runs of adjacent `.tool` items (length ≥2) into `.toolGroup`.
    ///
    /// Items that draw nothing (hidden debug events, empty `.thinking` blocks — see
    /// `rendersNothing`) never break a run: an invisible row landing between two tool
    /// calls must not split them into two cards with a gap where it used to be. Such
    /// items are held aside and re-emitted right after the group, so they still render
    /// in place when shown (e.g. "advanced detail" on) while the calls stay one block.
    public static func coalesceToolRuns(_ flat: [TranscriptItem]) -> [TranscriptItem] {
        var out: [TranscriptItem] = []
        var run: [ToolEntry] = []
        var held: [TranscriptItem] = []  // non-rendering rows seen mid-run, held so they don't split it
        func flush() {
            if run.count == 1 {
                let only = run[0]
                out.append(.tool(id: only.call.toolCallID, call: only.call,
                                 result: only.result, finished: only.finished, children: only.children))
            } else if run.count >= 2 {
                let calls = run.map(\.call)
                let firstID = run[0].call.toolCallID
                out.append(.toolGroup(id: "toolgroup-\(firstID)",
                                      signature: toolGroupSignature(calls), calls: run))
            }
            run.removeAll(keepingCapacity: true)
            out.append(contentsOf: held)
            held.removeAll(keepingCapacity: true)
        }
        for item in flat {
            if case .tool(_, let call, let result, let finished, let children) = item {
                run.append((call, result, finished, children))
            } else if item.rendersNothing, !run.isEmpty {
                held.append(item)  // inside a run: hold so it doesn't break the group
            } else {
                flush()
                out.append(item)
            }
        }
        flush()
        return out
    }

    /// A stable content hash of a run's tool calls (name + canonical input per call),
    /// used as the AFM-summary cache key. Stable once the calls' inputs are final, so a
    /// finished group is summarized exactly once; the store and the view derive it
    /// identically from the same calls.
    public static func toolGroupSignature(_ calls: [ToolCall]) -> Int {
        var hasher = Hasher()
        for call in calls {
            hasher.combine(call.name)
            hasher.combine(call.input.canonicalString())
        }
        return hasher.finalize()
    }

    /// The coalesced tool-call groups of the *main* conversation, for the store's summary
    /// cache: each group's signature, its calls, and whether every call has finished (only
    /// then is it stable enough to summarize). Scoped to the top level — a subagent's inner
    /// calls render literally in its card, so they aren't summarized.
    public static func toolGroups(_ events: [AgentEvent]) -> [(signature: Int, calls: [ToolCall], allFinished: Bool)] {
        coalesceToolRuns(flatItems(topLevelEvents(events))).compactMap { item in
            guard case .toolGroup(_, let signature, let calls) = item else { return nil }
            return (signature, calls.map(\.call), calls.allSatisfy(\.finished))
        }
    }

    /// Every unit the AI summarizer considers: each coalesced `.toolGroup` AND each lone
    /// `.tool`. A single tool call summarizes exactly like a one-call group would — a lone
    /// call renders in its own card purely for display, not because its headline is produced
    /// differently — so Bash summarization must reach lone calls too, not just runs of ≥2.
    /// A lone call's signature is `toolGroupSignature([call])`, matching what the view and
    /// the per-block cache derive for it. Scoped to the top level (see `toolGroups`).
    public static func summarizableUnits(_ events: [AgentEvent]) -> [(signature: Int, calls: [ToolCall], allFinished: Bool)] {
        coalesceToolRuns(flatItems(topLevelEvents(events))).compactMap { item in
            switch item {
            case .toolGroup(_, let signature, let calls):
                return (signature, calls.map(\.call), calls.allSatisfy(\.finished))
            case .tool(_, let call, _, let finished, _):
                return (toolGroupSignature([call]), [call], finished)
            default:
                return nil
            }
        }
    }

    private static func flatItems(_ events: [AgentEvent]) -> [TranscriptItem] {
        var items: [TranscriptItem] = []
        var indexByKey: [String: Int] = [:]
        // The in-flight thinking-tokens meter (if a run is still open) and the time of its
        // first ping, so each later ping updates that one row and reports elapsed time.
        var openTokensIndex: Int?
        var tokensRunStart = Date.distantPast

        func upsert(_ key: String, make: () -> TranscriptItem, update: (inout TranscriptItem) -> Void) {
            if let i = indexByKey[key] {
                update(&items[i])
            } else {
                indexByKey[key] = items.count
                items.append(make())
            }
        }

        // Fold a `system/thinking_tokens` ping into one live meter. The cumulative estimate
        // climbs within a thinking session, so a ping at or above the open row's total just
        // updates it (refreshing the delta and elapsed); a ping that drops below — a fresh
        // thinking session — starts a new meter.
        func updateThinkingTokens(total: Int, delta: Int, at when: Date, seq: UInt64) {
            if let i = openTokensIndex, case .thinkingTokens(let id, let prior, _, _) = items[i],
               total >= prior {
                items[i] = .thinkingTokens(
                    id: id, totalTokens: total, deltaTokens: delta,
                    elapsed: when.timeIntervalSince(tokensRunStart))
            } else {
                tokensRunStart = when
                openTokensIndex = items.count
                items.append(.thinkingTokens(
                    id: "thinktok-\(seq)", totalTokens: total, deltaTokens: delta, elapsed: 0))
            }
        }

        for event in events {
            // Thinking-tokens pings coalesce into the open meter; an interleaved thinking
            // delta keeps the run alive (it updates an existing row, appending nothing).
            // Any other event is a boundary that ends the run, so a later ping opens a new
            // meter in place rather than extending a stale one across a tool call or reply.
            if let tokens = parseThinkingTokens(event) {
                updateThinkingTokens(
                    total: tokens.total, delta: tokens.delta, at: event.at, seq: event.seq)
                continue
            }
            if case .thinking = event.kind {} else { openTokensIndex = nil }

            switch event.kind {
            case .assistantText(let chunk):
                upsert("assist-\(chunk.messageID)") {
                    .assistant(id: chunk.messageID, text: chunk.text, partial: chunk.isPartial)
                } update: { item in
                    guard case .assistant(let id, let existing, _) = item else { return }
                    // A delta appends to the running text; the finalized block is
                    // authoritative and replaces whatever streamed before it.
                    item = chunk.isPartial
                        ? .assistant(id: id, text: existing + chunk.text, partial: true)
                        : .assistant(id: id, text: chunk.text, partial: false)
                }
            case .thinking(let chunk):
                upsert("think-\(chunk.messageID)") {
                    .thinking(id: chunk.messageID, text: chunk.text, partial: chunk.isPartial)
                } update: { item in
                    guard case .thinking(let id, let existing, _) = item else { return }
                    item = chunk.isPartial
                        ? .thinking(id: id, text: existing + chunk.text, partial: true)
                        : .thinking(id: id, text: chunk.text, partial: false)
                }
            case .toolCallStarted(let call):
                upsert("tool-\(call.toolCallID)") {
                    .tool(id: call.toolCallID, call: call, result: nil, finished: false, children: [])
                } update: { _ in }
            case .toolCallCompleted(let call):
                // Completion carries the authoritative (fully-streamed) input;
                // preserve any result that already landed.
                upsert("tool-\(call.toolCallID)") {
                    .tool(id: call.toolCallID, call: call, result: nil, finished: true, children: [])
                } update: { item in
                    let existing: ToolResult? = { if case .tool(_, _, let r, _, _) = item { return r }; return nil }()
                    item = .tool(id: call.toolCallID, call: call, result: existing, finished: true, children: [])
                }
            case .toolResult(let result):
                // Fold the result into its tool call so it renders inside that
                // call's collapsible row. If the call isn't in view (e.g. a
                // replayed transcript), fall back to a standalone row.
                if let i = indexByKey["tool-\(result.toolCallID)"],
                   case .tool(let id, let call, _, let finished, let children) = items[i] {
                    items[i] = .tool(id: id, call: call, result: result, finished: finished, children: children)
                } else {
                    items.append(.event(event))
                }
            case .fileChange(let change):
                // An edit tool's inferred file change just repeats the path already on
                // that tool's row — fold it in (drop the separate purple "Update <path>"
                // line) when it belongs to a call in view. A change with no matching call
                // (other backends, or a replayed transcript) stays a standalone row.
                if let id = change.toolCallID, indexByKey["tool-\(id)"] != nil {
                    break
                } else {
                    items.append(.event(event))
                }
            case .toolCallInputDelta:
                break  // tool name shows on start; full input fills in on completion
            default:
                items.append(.event(event))
            }
        }
        return items
    }

    /// The cumulative + per-ping token estimate of a Claude `system/thinking_tokens`
    /// progress ping (`estimated_tokens` / `estimated_tokens_delta`), or nil for any other
    /// event. These passthrough pings arrive — frequently — only with "advanced detail" on;
    /// `flatItems` coalesces a run of them into one live `.thinkingTokens` meter.
    public static func parseThinkingTokens(_ event: AgentEvent) -> (total: Int, delta: Int)? {
        guard case .raw(let raw) = event.kind,
              event.nativeType == "system/thinking_tokens"
                || raw.native["subtype"]?.stringValue == "thinking_tokens",
              let total = raw.native["estimated_tokens"]?.intValue
        else { return nil }
        return (total, raw.native["estimated_tokens_delta"]?.intValue ?? 0)
    }
}
