import Foundation
import Combine

/// Narrow observable holder for the rapidly-mutating composer draft.
/// Keystrokes update this object instead of `ACPSession` so the transcript
/// pane is not invalidated while the user types in long sessions.
@MainActor
final class ACPComposerState: ObservableObject {
    private(set) var draft: ACPComposerDraft = .empty
    private(set) var revision: Int = 0

    func replaceDraft(_ draft: ACPComposerDraft) {
        objectWillChange.send()
        self.draft = draft
        revision += 1
    }
}

enum ACPSessionTitleSource: String, Codable {
    case placeholder
    case generated
    case manual
}

@MainActor
final class ACPSession: ObservableObject, Identifiable {
    typealias ID = String

    let id: ID
    let agentId: String
    let worktreeId: String
    let createdAt: Date
    let restoredFromPersistence: Bool
    let transcript = ACPTranscript()
    let composer = ACPComposerState()
    let terminalHost: ACPTerminalHost = ACPTerminalHost(sessionCwd: "/", sessionEnv: [:])

    @Published var title: String
    @Published var titleSource: ACPSessionTitleSource
    @Published var availableModels: [ACPModelInfo] = []
    @Published var availableModes: [ACPModeInfo] = []
    @Published var availableConfigOptions: [ACPConfigOption] = []
    @Published var currentModel: String?
    @Published var currentMode: String?
    @Published var promptSuggestions: [ACPPromptSuggestion] = []
    @Published var autoRunEnabled: Bool = false
    @Published var setupState: SetupState = .checking
    @Published var lastError: String?
    @Published var contextRestoreWarning: ContextRestoreWarning?
    @Published var contextRecoveryStatus: ContextRecoveryStatus?
    /// Runtime-only transcript scroll intent. When true, the ACP message
    /// list follows new content and restores to the latest bottom after
    /// returning to this session. Set false when the user scrolls upward.
    @Published var followsTranscriptTail: Bool = true
    /// Runtime-only user override for the inline plan sidebar. This is
    /// deliberately not persisted; it only survives while this in-memory
    /// ACP session object is retained.
    @Published var planSidebarMinimized: Bool = false
    /// Lifecycle state of the agent process backing this session.
    /// Single source of truth for the composer, header pill, and runner
    /// registry checks.
    /// Note: `AgentState.idle` means "no runner spawned yet" (process
    /// lifecycle), distinct from `StreamingState.idle` which means
    /// "runner is attached but not currently mid-prompt" (turn lifecycle).
    @Published var agentState: AgentState = .idle
    /// Prompt content capabilities learned from ACP `initialize`.
    /// Runtime-only: re-learned on each attach, never persisted. Drives
    /// send-time hydration in `ACPSessionRunner.hydrate`.
    @Published var promptCapabilities: ACPInitializeResult.ACPPromptCapabilities = .init()
    /// Auth methods learned from ACP `initialize`.
    /// Runtime-only: re-learned on each attach and used when an agent asks
    /// the client to authenticate before ACP can continue.
    @Published var authMethods: [ACPInitializeResult.ACPAuthMethod] = []
    /// Runtime-only auth method selected by the user in the sign-in banner.
    /// The next attach consumes it by calling ACP `authenticate` after
    /// initialize and before session creation/loading.
    var pendingAuthMethodId: String?
    /// Runtime-only first-run attach phase for the brief window between
    /// opening a brand-new ACP chat and receiving the ready remote session.
    /// This is never persisted; restored sessions derive their UI from
    /// persisted session fields instead.
    @Published var firstRunConnectingPhase: ACPFirstRunConnectingPhase?

    private var contextRecoveryExpiryTask: Task<Void, Never>?

    /// Runtime-only marker for a forced queue item parked behind the current
    /// `.sending` head. If that head fails, it moves behind this item so the
    /// explicit force-send remains next.
    private var forceSendAfterSendingHeadId: UUID?

    var imageInputSupported: Bool { promptCapabilities.image }
    var embeddedContextSupported: Bool { promptCapabilities.embeddedContext }

    enum AgentState: Equatable {
        case idle
        case spawning
        case ready
        case disconnected
        case failed(String)
    }
    /// Tracks whether the session's persisted state (messages, queue, draft,
    /// row fields) has been loaded from SQLite. `.loading` is the initial
    /// state for sessions returned by `placeholderSession`; `.ready` is the
    /// initial state for sessions returned by `createSession`. `.failed`
    /// surfaces a hydration error to the view.
    @Published var hydrationState: HydrationState
    /// ACP session id assigned by the agent on `session/new` (or the
    /// id we passed to `session/load`). Used for every subsequent
    /// protocol call (`session/prompt`, `session/cancel`, etc).
    /// Distinct from `id`, which is our locally-generated UUID used
    /// as the persistence key in `ACPSessionStore`.
    /// Persisted in `ACPSessionStore` and refreshed on each successful
    /// `session/new` or `session/load`.
    var remoteSessionId: String?
    @Published var queue: [QueuedPrompt] = []
    @Published var steerUndo: SteerUndoState?
    private var toolCallIndices: [String: Int] = [:]

    struct SteerUndoState: Equatable {
        /// Unique per-snapshot id used by SwiftUI for view diffing — letting
        /// us reset the 5s timer-task whenever a new steer happens before
        /// the previous toast has expired.
        let id: UUID
        let snapshot: [QueuedPrompt]
    }

    struct ContextRestoreWarning: Equatable {
        var message: String
        var canSendTranscript: Bool
    }

    enum ContextRecoveryStatus: Hashable {
        case restoring
        case sendingTranscript
        case restored
        case failed(String)
    }

    var hasConversationTranscript: Bool {
        transcript.messages.contains { message in
            switch message {
            case .user(_, let text, _):
                return !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            case .agent(_, let buffer):
                return !buffer.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
            default:
                return false
            }
        }
    }

    var composerDraft: ACPComposerDraft { composer.draft }
    var composerDraftRevision: Int { composer.revision }

    func replaceComposerDraft(_ draft: ACPComposerDraft) {
        composer.replaceDraft(draft)
    }

    enum HydrationState: Equatable {
        case loading
        case ready
        case failed(String)
    }
    enum StreamingState: Equatable { case idle, sending, streaming, awaitingPermission, awaitingInput }
    enum SetupState: Equatable {
        case checking
        case ready
        case needsSetup(reason: String)
        case needsAuth(methods: [ACPInitializeResult.ACPAuthMethod], reason: String?)
    }
    struct PendingPermission: Identifiable, Equatable {
        let id: JSONRPCID
        let params: ACPPermissionRequestParams
    }
    struct PendingQuestion: Identifiable, Equatable {
        let id: JSONRPCID
        let params: ACPQuestionRequestParams
    }

    init(id: ID, agentId: String, worktreeId: String, title: String,
         titleSource: ACPSessionTitleSource = .placeholder,
         createdAt: Date = Date(),
         hydrationState: HydrationState = .ready,
         restoredFromPersistence: Bool = false) {
        self.id = id
        self.agentId = agentId
        self.worktreeId = worktreeId
        self.title = title
        self.titleSource = titleSource
        self.createdAt = createdAt
        self.hydrationState = hydrationState
        self.restoredFromPersistence = restoredFromPersistence
    }

    deinit {
        contextRecoveryExpiryTask?.cancel()
        // Foundation cannot await main-actor methods from deinit; dispatch.
        let host = terminalHost
        Task { @MainActor in host.killAll() }
    }

    func recordUserPrompt(text: String, attachments: [ACPMessage.Attachment]) {
        transcript.messages.append(.user(id: UUID(), text: text, attachments: attachments))
        didAppendTranscriptMessage()
        transcript.completedOutputBoundaryMessageIds.removeAll()
        if titleSource == .placeholder {
            let candidate = text
                .trimmingCharacters(in: .whitespacesAndNewlines)
                .components(separatedBy: .newlines)
                .joined(separator: " ")
            let trimmed = String(candidate.prefix(60))
                .trimmingCharacters(in: .whitespacesAndNewlines)
            if !trimmed.isEmpty {
                title = trimmed
                titleSource = .generated
            }
        }
    }

    /// Returns the set of transcript message indices that were appended or
    /// mutated by this update. Updates that only touch non-message state
    /// (model lists, mode, etc) return an empty set. The caller uses this
    /// to persist exactly the rows that changed — necessary because a
    /// `.plan` or `.toolCallUpdate` can mutate a message anywhere in the
    /// transcript, not just the trailing row.
    @discardableResult
    func apply(_ update: ACPSessionUpdate) -> Set<Int> {
        switch update {
        case .agentMessageChunk(let block):
            clearRestoredContextRecoveryStatus()
            let txt = text(of: block)
            let i = appendStreaming(text: txt, locate: { lastAgent() },
                                    makeNew: { .agent(id: UUID(), StreamingText(txt)) })
            return [i]
        case .userMessageChunk(let block):
            // Agents rarely emit these; treat as informational.
            transcript.messages.append(.systemNotice(id: UUID(), text: text(of: block)))
            didAppendTranscriptMessage()
            transcript.completedOutputBoundaryMessageIds.removeAll()
            return [transcript.messages.count - 1]
        case .agentThoughtChunk(let block):
            clearRestoredContextRecoveryStatus()
            let txt = text(of: block)
            let i = appendStreaming(text: txt, locate: { lastThought() },
                                    makeNew: { .thought(id: UUID(), StreamingText(txt)) })
            return [i]
        case .toolCall(let payload):
            clearRestoredContextRecoveryStatus()
            let items = payload.content ?? []
            let raw = Self.flatten(items)
            let full = Self.stripWrappingFence(raw,
                                               isFinal: Self.isFinalStatus(payload.status))
            transcript.messages.append(.toolCall(.init(
                toolCallId: payload.toolCallId,
                title: payload.title,
                kind: payload.kind,
                status: payload.status,
                content: full,
                preview: Self.previewLine(full),
                contentLanguage: Self.wrappingFenceLanguage(raw),
                locations: payload.locations?.map(\.path) ?? [],
                terminalIds: Self.extractTerminalIds(items))))
            didAppendTranscriptMessage()
            transcript.completedOutputBoundaryMessageIds.removeAll()
            return [transcript.messages.count - 1]
        case .toolCallUpdate(let u):
            clearRestoredContextRecoveryStatus()
            let touched = updateToolCall(id: u.toolCallId) { tc in
                if let s = u.status { tc.status = s }
                if let c = u.content {
                    // ACP content updates are full replacement snapshots,
                    // so terminalIds tracks the *current* content — assign
                    // unconditionally, including the empty case, so a
                    // text/diff-only final update doesn't leave a stale
                    // terminal tail rendering in the card.
                    let raw = Self.flatten(c)
                    let full = Self.stripWrappingFence(raw,
                                                      isFinal: Self.isFinalStatus(tc.status))
                    tc.content = full
                    tc.preview = Self.previewLine(full)
                    tc.contentLanguage = Self.wrappingFenceLanguage(raw)
                    tc.terminalIds = Self.extractTerminalIds(c)
                }
            }
            return touched.map { [$0] } ?? []
        case .plan(let entries):
            clearRestoredContextRecoveryStatus()
            let items = entries.map { ACPMessage.PlanItem(content: $0.content, status: $0.status) }
            // Overwrite the existing plan in place only if it belongs to
            // the current turn (i.e. sits after the latest user prompt).
            // Otherwise append a fresh plan so the previous turn's plan
            // stays at its original position and the new one is current.
            let lastUserIdx = transcript.messages.lastIndex { if case .user = $0 { return true } else { return false } } ?? -1
            let currentTurnPlanIdx = transcript.messages.lastIndex { if case .plan = $0 { return true } else { return false } }
                .flatMap { $0 > lastUserIdx ? $0 : nil }
            if let i = currentTurnPlanIdx, case .plan(let existingId, _) = transcript.messages[i] {
                transcript.messages[i] = .plan(id: existingId, items)
                return [i]
            } else {
                transcript.messages.append(.plan(id: UUID(), items))
                didAppendTranscriptMessage()
                transcript.completedOutputBoundaryMessageIds.removeAll()
                return [transcript.messages.count - 1]
            }
        case .availableModelsUpdate(let ms):
            availableModels = ms
            return []
        case .currentModeUpdate(let modeId):
            currentMode = modeId
            return []
        case .currentModelUpdate(let modelId):
            currentModel = modelId
            return []
        case .sessionConfigOptionsUpdate(let opts):
            availableConfigOptions = opts
            return []
        case .availableCommandsUpdate(let cmds):
            promptSuggestions = cmds
            return []
        case .unknown:
            return []
        }
    }

    func markContextRecoveryRestored(expiryNanoseconds: UInt64 = 30_000_000_000) {
        contextRecoveryExpiryTask?.cancel()
        contextRecoveryStatus = .restored
        contextRecoveryExpiryTask = Task { @MainActor [weak self] in
            try? await Task.sleep(nanoseconds: expiryNanoseconds)
            guard !Task.isCancelled, self?.contextRecoveryStatus == .restored else { return }
            self?.contextRecoveryStatus = nil
            self?.contextRecoveryExpiryTask = nil
        }
    }

    func appendSystemNotice(_ text: String) {
        transcript.messages.append(.systemNotice(id: UUID(), text: text))
        didAppendTranscriptMessage()
    }

    func appendFileEdit(_ edit: ACPMessage.FileEdit) {
        clearRestoredContextRecoveryStatus()
        transcript.messages.append(.fileEdit(id: UUID(), edit))
        didAppendTranscriptMessage()
        transcript.completedOutputBoundaryMessageIds.removeAll()
    }

    func replaceTranscriptMessages(_ messages: [ACPMessage]) {
        transcript.messages = messages
        rebuildToolCallIndices()
    }

    /// Insert `older` at the head of the transcript and shift `visibleHead`
    /// forward by `older.count` so the visible tail window stays anchored to
    /// the same messages. Used by the post-hydration backfill that prepends
    /// pre-tail messages after the UI has already painted the tail.
    func prependTranscriptMessages(_ older: [ACPMessage]) {
        guard !older.isEmpty else { return }
        transcript.messages.insert(contentsOf: older, at: 0)
        // Rebuild tool-call indices because every prior entry's index just
        // shifted by `older.count`. Cheaper than offsetting in place: the
        // cache is dictionary-typed, so a full rebuild is O(N) and avoids
        // any chance of drift if a streaming update lands mid-shift.
        rebuildToolCallIndices()
        // Keep the visible window anchored to the tail the user is already
        // looking at. Bypass `setVisibleHead` — it strips markdown caches
        // for messages below the new head, which is exactly what we want
        // to *avoid* here (older messages weren't visible before the shift
        // and never had caches; the tail's caches must survive untouched).
        transcript.visibleHead = transcript.visibleHead + older.count
    }

    func markCompletedOutputBoundary() {
        transcript.completedOutputBoundaryMessageIds.removeAll()
        for message in transcript.messages.reversed() {
            switch message {
            case .agent, .thought:
                transcript.completedOutputBoundaryMessageIds.insert(message.stableId)
                continue
            case .plan:
                continue
            default:
                return
            }
        }
    }

    /// Append a new pending item to the tail of the queue. Used by the
    /// runner when the user submits while the agent is busy (or while
    /// the queue is already non-empty — see ACPSubmitRoute).
    func enqueue(blocks: [ACPContentBlock], draft: ACPComposerDraft? = nil) {
        queue.append(QueuedPrompt(blocks: blocks, draft: draft))
    }

    /// Remove a specific item by id. The drag-handle X on the bubble
    /// calls this. Safe on .sending items because the UI hides X then —
    /// but we double-guard here to avoid yanking an in-flight RPC.
    func removeFromQueue(id: UUID) {
        guard let idx = queue.firstIndex(where: { $0.id == id }) else { return }
        if queue[idx].status == .sending { return }
        if forceSendAfterSendingHeadId == id {
            forceSendAfterSendingHeadId = nil
        }
        queue.remove(at: idx)
    }

    /// Pull a queued item back into the composer for editing: remove it and
    /// return its restorable draft — but ONLY if it's still `.pending`.
    /// Returns `nil` if the item is gone or already `.sending` (mid-RPC), so
    /// the caller never restores a prompt that's still in flight. This is the
    /// atomic replacement for "check status, then removeFromQueue, then
    /// restore" — collapsing the time-of-check/time-of-use window that let a
    /// flusher-promoted item be duplicated into the composer.
    func takeForEditing(id: UUID) -> ACPComposerDraft? {
        guard let idx = queue.firstIndex(where: { $0.id == id }) else { return nil }
        guard queue[idx].status == .pending else { return nil }
        if forceSendAfterSendingHeadId == id {
            forceSendAfterSendingHeadId = nil
        }
        let item = queue.remove(at: idx)
        return item.restorableDraft
    }

    /// Reorder within the queue. Refuses to move a `.sending` head — the
    /// UI hides the grip on `.sending` items, this is the belt-and-
    /// suspenders guard.
    func moveInQueue(from src: Int, to dst: Int) {
        guard src >= 0, src < queue.count, dst >= 0, dst <= queue.count else { return }
        if queue.indices.contains(src), queue[src].status == .sending { return }
        // If moving across the .sending head (index 0 when sending), refuse.
        if !queue.isEmpty, queue[0].status == .sending, dst == 0 { return }
        let item = queue.remove(at: src)
        queue.insert(item, at: min(dst, queue.count))
    }

    /// Promote a pending queued item to the next drainable position and clear
    /// any previous send error. If a `.sending` head is already in-flight, the
    /// forced item is placed immediately behind it so completion can pop the
    /// current head safely.
    @discardableResult
    func forceQueueItem(id: UUID) -> Bool {
        guard let idx = queue.firstIndex(where: { $0.id == id }) else { return false }
        guard queue[idx].status == .pending else { return false }

        let protectedPrefixCount = (queue.first?.status == .sending) ? 1 : 0
        for bypassedIndex in protectedPrefixCount ..< idx {
            if queue[bypassedIndex].status == .pending,
               queue[bypassedIndex].lastError != nil {
                queue[bypassedIndex].transcriptRecorded = false
            }
        }

        var item = queue.remove(at: idx)
        item.status = .pending
        item.lastError = nil

        let insertAt = protectedPrefixCount
        queue.insert(item, at: min(insertAt, queue.count))
        forceSendAfterSendingHeadId = protectedPrefixCount == 1 ? item.id : nil
        return true
    }

    /// Edit the prompt blocks of a `.pending` item. No-op for `.sending`.
    /// Clears any captured draft — the old segments no longer describe the
    /// new blocks, so a later edit falls back to the heuristic on the
    /// current blocks rather than restoring stale structure.
    ///
    /// This is the only correct path to mutate a queued item's `blocks` —
    /// call it rather than writing `queue[idx].blocks` directly, otherwise a
    /// now-stale `draft` survives and mis-restores on the next edit.
    func editQueueItem(id: UUID, blocks: [ACPContentBlock]) {
        guard let idx = queue.firstIndex(where: { $0.id == id }) else { return }
        if queue[idx].status == .sending { return }
        queue[idx].blocks = blocks
        queue[idx].draft = nil
    }

    /// Remove all `.pending` items; return the snapshot in original order so
    /// the steer-undo toast (or the "Clear queue" header button) can restore
    /// them. A `.sending` item is left in place — it's mid-RPC.
    @discardableResult
    func clearPendingQueue() -> [QueuedPrompt] {
        let snapshot = queue.filter { $0.status == .pending }
        queue.removeAll { $0.status == .pending }
        forceSendAfterSendingHeadId = nil
        return snapshot
    }

    /// Re-prepend a previously-cleared snapshot. Used by the steer-undo
    /// toast. If a `.sending` head exists (e.g. the steer redirect already
    /// completed and the flusher promoted a follow-up to in-flight before
    /// the user tapped Undo), the restored items go AFTER it — otherwise
    /// the in-flight `sendNow`'s `popQueueHead` would key off the wrong
    /// item and the `.sending` head would stay stranded in the queue.
    func restorePendingSnapshot(_ snapshot: [QueuedPrompt]) {
        let insertAt = (queue.first?.status == .sending) ? 1 : 0
        queue.insert(contentsOf: snapshot, at: insertAt)
    }

    /// Number of pending queue items. The transcript UI may render additional
    /// queue rows, such as an in-flight `.sending` head, for row-local status
    /// and action placement.
    var visibleQueueCount: Int {
        queue.reduce(0) { $0 + ($1.status == .sending ? 0 : 1) }
    }

    /// Mark the head item `.sending`. Called by the flusher right before
    /// it spawns the prompt RPC. Clears any previous `lastError` so a
    /// retried item displays cleanly while in-flight.
    func markQueueHeadSending() {
        guard !queue.isEmpty, queue[0].status == .pending else { return }
        queue[0].status = .sending
        queue[0].lastError = nil
    }

    /// Pop the head only if it's currently `.sending`. Returns it. Used by
    /// the flusher's success path.
    @discardableResult
    func popQueueHead() -> QueuedPrompt? {
        guard !queue.isEmpty, queue[0].status == .sending else { return nil }
        let item = queue.removeFirst()
        if forceSendAfterSendingHeadId == item.id {
            forceSendAfterSendingHeadId = nil
        }
        return item
    }

    /// Roll the `.sending` head back to `.pending` with an error message.
    /// Used by the flusher's failure path. Keeps the item at the head so
    /// the user sees "Retry" on the bubble.
    func setQueueHeadError(_ message: String) {
        guard !queue.isEmpty else { return }
        var item = queue.removeFirst()
        item.status = .pending
        item.lastError = message

        if let forcedId = forceSendAfterSendingHeadId,
           let forcedIndex = queue.firstIndex(where: { $0.id == forcedId }) {
            queue.insert(item, at: min(forcedIndex + 1, queue.count))
        } else {
            queue.insert(item, at: 0)
        }
        forceSendAfterSendingHeadId = nil
    }

    /// Replace the queue wholesale with a normalized restore set. Called
    /// from `ACPSessionManager.openSession` after pulling rows from the
    /// store. `.sending` items get flipped to `.pending` here so the
    /// flusher re-attempts on next idle.
    func restoreQueue(_ items: [QueuedPrompt]) {
        forceSendAfterSendingHeadId = nil
        queue = items.map { $0.normalizedAfterRestore() }
    }

    /// Mark any pending/in_progress tool calls as canceled. Called when
    /// the user interrupts streaming so spinners stop and the rendered
    /// status reflects reality (the agent isn't coming back to finish
    /// these). Returns the indices of mutated messages so callers can
    /// persist them.
    func cancelInFlightToolCalls() -> [Int] {
        var changed: [Int] = []
        for i in transcript.messages.indices {
            if case .toolCall(var tc) = transcript.messages[i],
               tc.status == "in_progress" || tc.status == "pending" {
                tc.status = "canceled"
                transcript.messages[i] = .toolCall(tc)
                changed.append(i)
            }
        }
        return changed
    }

    /// Composer-facing normalized chip state. Recomputed on each access
    /// from the current `availableModels` / `availableModes` /
    /// `availableConfigOptions` / `currentModel` / `currentMode`. Cheap —
    /// the inputs are small lists.
    var chipState: ACPChipState {
        ACPChipState.normalize(
            agentId: agentId,
            availableModels: availableModels,
            currentModel: currentModel,
            availableModes: availableModes,
            currentMode: currentMode,
            configOptions: availableConfigOptions)
    }

    // MARK: helpers

    /// Concatenate tool-call content entries into one text blob suitable
    /// for the expanded tool card. Non-text variants are rendered as
    /// readable placeholders so the user can see something happened.
    private static func flatten(_ items: [ACPToolCallContent]) -> String {
        var out: [String] = []
        for item in items {
            switch item {
            case .content(.text(let s)):
                out.append(s)
            case .content(.resourceLink(let uri, let name)):
                out.append("[\(name ?? uri)]")
            case .content(.image):
                out.append("[image]")
            case .content(.resource(let uri, _, _)):
                out.append("[\(URL(string: uri)?.lastPathComponent ?? uri)]")
            case .diff(let path, let old, let new):
                var lines: [String] = ["--- \(path)"]
                if let old, !old.isEmpty {
                    for line in Self.diffLines(old) {
                        lines.append("-\(line)")
                    }
                }
                for line in Self.diffLines(new) {
                    lines.append("+\(line)")
                }
                out.append(lines.joined(separator: "\n"))
            case .terminal:
                // IDs are tracked on tc.terminalIds; the UI renders the live
                // tail structurally, so no text placeholder is appended here.
                continue
            case .unknown:
                // Skipped rather than rendered empty.
                continue
            }
        }
        return out.joined(separator: "\n")
    }

    /// Split a diff hunk into lines while preserving blank lines inside
    /// the content. Drops the empty trailing substring that
    /// `split(omittingEmptySubsequences: false)` produces for inputs
    /// ending in a newline — without this, every normal source-file diff
    /// renders with a spurious empty `-` or `+` line.
    private static func diffLines(_ s: String) -> [Substring] {
        var parts = s.split(separator: "\n", omittingEmptySubsequences: false)
        if parts.last?.isEmpty == true { parts.removeLast() }
        return parts
    }

    /// First non-empty line of `full`, truncated to ~80 chars. Used as
    /// the collapsed-row teaser.
    private static func previewLine(_ full: String) -> String? {
        let firstLine = full.split(separator: "\n", omittingEmptySubsequences: true).first
            ?? full[full.startIndex..<full.endIndex]
        let s = String(firstLine).trimmingCharacters(in: .whitespacesAndNewlines)
        guard !s.isEmpty else { return nil }
        return s.count > 80 ? String(s.prefix(80)) + "…" : s
    }

    private static func extractTerminalIds(_ items: [ACPToolCallContent]) -> [String] {
        items.compactMap { if case .terminal(let id) = $0 { return id } else { return nil } }
    }

    /// Best-effort strip of a single pair of markdown code fences that
    /// wrap the entire tool output. Claude sometimes returns its output
    /// inside a ```…``` block; Codex returns raw text. The opening fence
    /// is only recognized at line 1 col 0 so a fence inside the real
    /// output is left alone. The trailing fence is dropped only when
    /// `isFinal` is true — mid-stream we can't tell a closing ``` from a
    /// line of real output that happens to be three backticks.
    static func stripWrappingFence(_ full: String, isFinal: Bool) -> String {
        guard !full.isEmpty else { return full }
        var lines = full.split(separator: "\n", omittingEmptySubsequences: false).map(String.init)
        guard let first = lines.first, isOpeningFence(first) else { return full }
        lines.removeFirst()
        if isFinal {
            var trailingEmpty = 0
            while let last = lines.last, last.isEmpty {
                lines.removeLast()
                trailingEmpty += 1
            }
            if lines.last == "```" {
                lines.removeLast()
            } else {
                for _ in 0..<trailingEmpty { lines.append("") }
            }
        }
        return lines.joined(separator: "\n")
    }

    static func wrappingFenceLanguage(_ full: String) -> String? {
        guard let first = full.split(separator: "\n", omittingEmptySubsequences: false).first else {
            return nil
        }
        let line = String(first)
        guard line.hasPrefix("```") else { return nil }
        return ACPCodeLanguage.highlighterExtension(for: String(line.dropFirst(3)))
    }

    private static func isOpeningFence(_ line: String) -> Bool {
        guard line.hasPrefix("```") else { return false }
        let rest = line.dropFirst(3)
        return rest.allSatisfy { $0.isLetter || $0.isNumber || $0 == "_" || $0 == "+" || $0 == "-" }
    }

    private static func isFinalStatus(_ status: String) -> Bool {
        switch status {
        case "completed", "failed", "canceled", "cancelled": return true
        default: return false
        }
    }

    private func text(of block: ACPContentBlock) -> String {
        if case .text(let s) = block { return s }
        return ""
    }

    private func lastAgent() -> Int? {
        for i in stride(from: transcript.messages.count - 1, through: 0, by: -1) {
            // STOP on .user — every new user turn starts a fresh
            // agent reply. Without this the agent's response to a NEW
            // prompt gets appended to the previous turn's trailing
            // agent message, breaking the conversation order.
            if case .user = transcript.messages[i] { return nil }
            if case .agent = transcript.messages[i] { return i }
            if case .toolCall = transcript.messages[i] { return nil }
            if case .fileEdit = transcript.messages[i] { return nil }
        }
        return nil
    }
    private func lastThought() -> Int? {
        for i in stride(from: transcript.messages.count - 1, through: 0, by: -1) {
            if case .user = transcript.messages[i] { return nil }
            if case .thought = transcript.messages[i] { return i }
            if case .agent = transcript.messages[i] { return nil }
            if case .toolCall = transcript.messages[i] { return nil }
        }
        return nil
    }
    /// Returns the index of the message that was appended or mutated.
    private func appendStreaming(text addition: String,
                                locate: () -> Int?,
                                makeNew: () -> ACPMessage) -> Int {
        if let i = locate() {
            let stableId = transcript.messages[i].stableId
            if transcript.completedOutputBoundaryMessageIds.contains(stableId) {
                transcript.completedOutputBoundaryMessageIds.remove(stableId)
            } else {
                switch transcript.messages[i] {
                case .agent(_, let buf), .thought(_, let buf):
                    buf.append(addition)
                    transcript.streamingTick &+= 1
                    return i
                default:
                    break
                }
            }
        }
        transcript.messages.append(makeNew())
        didAppendTranscriptMessage()
        return transcript.messages.count - 1
    }
    /// Returns the index of the matching tool call, or nil if no match.
    private func updateToolCall(id: String, _ mutate: (inout ACPMessage.ToolCall) -> Void) -> Int? {
        if let i = toolCallIndices[id],
           transcript.messages.indices.contains(i),
           case .toolCall(var tc) = transcript.messages[i],
           tc.toolCallId == id {
            mutate(&tc)
            transcript.messages[i] = .toolCall(tc)
            return i
        }

        for i in transcript.messages.indices {
            if case .toolCall(var tc) = transcript.messages[i], tc.toolCallId == id {
                toolCallIndices[id] = i
                mutate(&tc)
                transcript.messages[i] = .toolCall(tc)
                return i
            }
        }
        return nil
    }

    private func didAppendTranscriptMessage() {
        let index = transcript.messages.count - 1
        if case .toolCall(let tc) = transcript.messages[index] {
            toolCallIndices[tc.toolCallId] = index
        }
        advanceRenderWindowIfFollowingTail()
    }

    private func clearRestoredContextRecoveryStatus() {
        guard contextRecoveryStatus == .restored else { return }
        contextRecoveryExpiryTask?.cancel()
        contextRecoveryExpiryTask = nil
        contextRecoveryStatus = nil
    }

    private func rebuildToolCallIndices() {
        toolCallIndices.removeAll(keepingCapacity: true)
        for i in transcript.messages.indices {
            if case .toolCall(let tc) = transcript.messages[i] {
                toolCallIndices[tc.toolCallId] = i
            }
        }
    }

    private func advanceRenderWindowIfFollowingTail() {
        guard followsTranscriptTail else { return }
        transcript.setVisibleHead(max(0, transcript.messages.count - ACPTranscript.tailWindow))
    }
}
