import Foundation
import NucleicCore

/// Identity of the projection currently installed in the transcript view.
///
/// `renderedItems` is deliberately the post-filter/post-cleanup list. Its last id can belong to
/// prose, a tool, an autoship note, or any other visible event; consumers must never substitute a
/// "last message" cursor when deciding where the transcript ends.
struct TranscriptRenderRevision: Equatable {
    let session: SessionID?
    let transcriptVersion: Int
    /// Raw-stream head identity. Tail-first startup preview replacement prepends history; this
    /// changes even if coalescing preserves the same projected row count and endpoint ids.
    let firstEventSeq: UInt64?
    let tailItemID: String?

    init(
        session: SessionID?,
        transcriptVersion: Int,
        firstEventSeq: UInt64? = nil,
        renderedItems: [TranscriptItem]
    ) {
        self.session = session
        self.transcriptVersion = transcriptVersion
        self.firstEventSeq = firstEventSeq
        self.tailItemID = renderedItems.last?.id
    }
}

/// Stable render batches for the macOS transcript.
///
/// Short transcripts use one exact-height eager stack. Long transcripts hand these batches to a
/// native macOS `List`, whose AppKit-backed row virtualization keeps only nearby batches mounted
/// and caches their resolved heights. This is deliberately not a `LazyVStack`: that container's
/// changing off-screen height estimates moved mixed Markdown/tool histories under the pointer.
/// Small eager batches give the native list a useful reuse unit while a live-tail change only
/// rebuilds the final batch.
struct TranscriptRenderSegment: Identifiable, Equatable, Sendable {
    let range: Range<Int>

    /// Ranges always start at fixed multiples of `rowsPerSegment`, so the lower bound remains
    /// stable as rows append to the live tail.
    var id: Int { range.lowerBound }

    /// Cheap vertical-length metadata available before the segment's view exists. Long-history
    /// rendering uses it to create every reserved canvas slot immediately; a persisted measured
    /// height refines the slot when one exists, while this row count supplies the cold-cache
    /// fallback without touching any `TranscriptRow`/TextKit body.
    var length: Int { range.count }
}

/// Cheap content-derived cold height for a native-list segment. This is computed beside transcript
/// projection, away from the main actor, and prevents the old fixed 48pt-per-item reservation from
/// understating a multi-page assistant response by an order of magnitude. Persisted measurements
/// still win whenever available.
struct TranscriptRenderSegmentEstimate: Equatable, Sendable {
    private static let referenceWidth = 720.0
    private static let referenceCharactersPerLine = 92.0

    let fixedHeight: Double
    let wrappingHeightAtReferenceWidth: Double

    func height(contentWidth: Int) -> Double {
        let width = Double(max(240, contentWidth))
        let scale = min(2.8, max(0.72, Self.referenceWidth / width))
        return max(28, fixedHeight + wrappingHeightAtReferenceWidth * scale)
    }

    static func measure(_ items: ArraySlice<TranscriptItem>) -> Self {
        var fixed = 0.0
        var wrapping = 0.0

        func wrappedHeight(_ text: String, lineHeight: Double, maxLines: Int? = nil) -> Double {
            guard !text.isEmpty else { return lineHeight }
            var lines = 0
            for sourceLine in text.split(separator: "\n", omittingEmptySubsequences: false) {
                let count = max(1, sourceLine.utf8.count)
                lines += max(1, Int(ceil(Double(count) / referenceCharactersPerLine)))
                if let maxLines, lines >= maxLines {
                    lines = maxLines
                    break
                }
            }
            return Double(max(1, lines)) * lineHeight
        }

        func markdownHeight(_ text: String) -> (fixed: Double, wrapping: Double) {
            var fixed = 0.0
            var wrapping = 0.0
            var codeLines = 0
            var inCode = false
            for line in text.split(separator: "\n", omittingEmptySubsequences: false) {
                if String(line).trimmingCharacters(in: .whitespaces).hasPrefix("```") {
                    if inCode {
                        fixed += Double(max(1, codeLines)) * 20 + 16
                        codeLines = 0
                    }
                    inCode.toggle()
                } else if inCode {
                    codeLines += 1
                } else if line.isEmpty {
                    fixed += 10
                } else {
                    let count = max(1, line.utf8.count)
                    let visualLines = max(
                        1, Int(ceil(Double(count) / referenceCharactersPerLine)))
                    wrapping += Double(visualLines) * 22
                }
            }
            if inCode { fixed += Double(max(1, codeLines)) * 20 + 16 }
            return (fixed, max(22, wrapping))
        }

        for item in items {
            switch item {
            case .assistant(_, let text, _):
                let estimate = markdownHeight(text)
                fixed += estimate.fixed
                wrapping += estimate.wrapping
            case .thinking(_, let text, _):
                fixed += 18
                wrapping += wrappedHeight(text, lineHeight: 20)
            case .tool(_, _, _, _, _):
                fixed += 64
            case .toolGroup(_, _, let calls):
                fixed += 32 + Double(max(1, calls.count)) * 38
            case .thinkingTokens:
                fixed += 38
            case .event(let event):
                switch event.kind {
                case .userText(let chunk):
                    fixed += 44
                    wrapping += wrappedHeight(chunk.text, lineHeight: 21, maxLines: 12)
                case .assistantText(let chunk):
                    let estimate = markdownHeight(chunk.text)
                    fixed += estimate.fixed
                    wrapping += estimate.wrapping
                case .thinking(let chunk):
                    fixed += 18
                    wrapping += wrappedHeight(chunk.text, lineHeight: 20)
                case .fileChange:
                    fixed += 64
                case .error(let error):
                    fixed += 26
                    wrapping += wrappedHeight(error.message, lineHeight: 18, maxLines: 3)
                case .note(let note):
                    fixed += 20
                    wrapping += wrappedHeight(note.text, lineHeight: 18, maxLines: 3)
                default:
                    fixed += 38
                }
            }
        }
        return Self(fixedHeight: fixed, wrappingHeightAtReferenceWidth: wrapping)
    }
}

/// Incrementally maintains fixed row ranges. Appending a streaming row only extends the final
/// segment or adds one segment; it never re-chunks settled history.
struct TranscriptRenderSegmenter {
    /// Below this size the eager hierarchy is cheap and retains perfectly exact initial geometry.
    /// Beyond it, removing off-screen SwiftUI/TextKit trees dominates the small cost of native
    /// list row virtualization.
    static let virtualizationThreshold = 96
    /// Keep hydration work small enough to land within a frame even when a row contains Markdown
    /// or a tool card. Six-row batches made one unlucky segment a multi-second unit on massive
    /// histories; two rows retain useful native-list reuse while bounding each promotion tightly.
    static let rowsPerSegment = 2
    private(set) var rowCount = 0
    private(set) var segments: [TranscriptRenderSegment] = []

    static func shouldVirtualize(rowCount: Int) -> Bool {
        rowCount > virtualizationThreshold
    }

    /// Every render segment participates in progressive preparation, newest first. Native `List`
    /// still decides which prepared bodies are mounted, so covering the whole order does not
    /// eagerly instantiate off-screen SwiftUI/TextKit trees.
    static func hydrationOrder(
        _ segments: [TranscriptRenderSegment]
    ) -> [TranscriptRenderSegment] {
        Array(segments.reversed())
    }

    mutating func update(rowCount requestedCount: Int) -> [TranscriptRenderSegment] {
        let newCount = max(0, requestedCount)
        guard newCount != rowCount else { return segments }

        if newCount < rowCount {
            // Revert, filter change, or transcript replacement: rebuild the cheap range table.
            segments = Self.makeSegments(rowCount: newCount)
        } else {
            var cursor = rowCount
            if let lastIndex = segments.indices.last,
               segments[lastIndex].range.count < Self.rowsPerSegment {
                let start = segments[lastIndex].range.lowerBound
                let end = min(newCount, start + Self.rowsPerSegment)
                segments[lastIndex] = TranscriptRenderSegment(range: start..<end)
                cursor = end
            }
            while cursor < newCount {
                let end = min(newCount, cursor + Self.rowsPerSegment)
                segments.append(TranscriptRenderSegment(range: cursor..<end))
                cursor = end
            }
        }

        rowCount = newCount
        return segments
    }

    private static func makeSegments(rowCount: Int) -> [TranscriptRenderSegment] {
        guard rowCount > 0 else { return [] }
        return stride(from: 0, to: rowCount, by: rowsPerSegment).map { start in
            TranscriptRenderSegment(range: start..<min(rowCount, start + rowsPerSegment))
        }
    }
}
