import Foundation
import SwiftUI

/// A lightweight Markdown renderer for chat messages — the exact port of the Mac's
/// `MarkdownText`: fenced code blocks, headings, bullet/numbered lists, GitHub-style tables,
/// and inline emphasis/links/`code`. Not a full CommonMark implementation — just the
/// constructs agents actually emit — so a reply renders identically on both devices.
struct MarkdownText: View {
    let markdown: String
    /// Base prose size; every other size (headings, code, tables) is derived from it so a
    /// response renders on one consistent scale. Scaled with the user's text-size setting
    /// (which drives `.dynamicTypeSize`), anchored to the Mac's transcript prose size.
    @ScaledMetric(relativeTo: .body) private var bodySize: CGFloat = 15

    var body: some View {
        VStack(alignment: .leading, spacing: 8) {
            ForEach(Array(Self.parse(markdown).enumerated()), id: \.offset) { _, block in
                switch block {
                case .code(let code):
                    codeBlock(code)
                case .text(let text):
                    textBlock(text)
                case .table(let rows):
                    tableView(rows)
                }
            }
        }
        // Anchor the whole response to one base size; inline code and body text
        // inherit it, so nothing drifts smaller than the prose around it.
        .font(.system(size: bodySize))
        // Soft off-white (vs. pure white) so chat prose reads calmly at night.
        .foregroundStyle(Palette.primaryText)
    }

    // MARK: - blocks

    private enum Block { case code(String), text(String), table([[String]]) }

    /// Parsed-block cache. The same message string is re-parsed on every body re-evaluation
    /// (scrolling, a sibling row streaming) and on every chat reopen, yet structural parsing
    /// is independent of `bodySize` — so the source string is a complete key. Bounded;
    /// `NSCache` also evicts under memory pressure.
    ///
    /// The whole parse pipeline (both caches and the statics below) is `nonisolated`: `prewarm`
    /// runs it from a detached background task by design, and as `View` statics they'd otherwise
    /// be implicitly MainActor (a Swift 6 error for that call). `(unsafe)` on the caches is
    /// sound because `NSCache` is thread-safe and the boxed values are immutable.
    private final class ParsedBlocks { let blocks: [Block]; init(_ b: [Block]) { self.blocks = b } }
    nonisolated(unsafe) private static let blockCache: NSCache<NSString, ParsedBlocks> = {
        let cache = NSCache<NSString, ParsedBlocks>()
        cache.countLimit = 2048
        return cache
    }()

    nonisolated private static func parse(_ markdown: String) -> [Block] {
        let key = markdown as NSString
        if let hit = blockCache.object(forKey: key) { return hit.blocks }
        let blocks = parseUncached(markdown)
        blockCache.setObject(ParsedBlocks(blocks), forKey: key)
        return blocks
    }

    nonisolated private static func parseUncached(_ markdown: String) -> [Block] {
        var blocks: [Block] = []
        var textBuffer: [String] = []
        func flush() {
            let joined = textBuffer.joined(separator: "\n").trimmingCharacters(in: .whitespacesAndNewlines)
            if !joined.isEmpty { blocks.append(.text(joined)) }
            textBuffer = []
        }
        let lines = markdown.components(separatedBy: "\n")
        var index = 0
        while index < lines.count {
            if lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
                flush()
                var code: [String] = []
                index += 1
                while index < lines.count,
                    !lines[index].trimmingCharacters(in: .whitespaces).hasPrefix("```") {
                    code.append(lines[index])
                    index += 1
                }
                blocks.append(.code(code.joined(separator: "\n")))
                index += 1  // consume closing fence
            } else if isTableStart(lines, index) {
                flush()
                var rows: [[String]] = [tableCells(lines[index])]
                index += 2  // header row + the |---|--- separator
                while index < lines.count, lines[index].contains("|"),
                    !lines[index].trimmingCharacters(in: .whitespaces).isEmpty {
                    rows.append(tableCells(lines[index]))
                    index += 1
                }
                blocks.append(.table(rows))
            } else {
                textBuffer.append(lines[index])
                index += 1
            }
        }
        flush()
        return blocks
    }

    // MARK: - tables

    /// A GitHub-style table: a `|`-bearing header line immediately followed by a
    /// `|---|:--:|` separator line.
    nonisolated private static func isTableStart(_ lines: [String], _ index: Int) -> Bool {
        guard lines[index].contains("|"), index + 1 < lines.count else { return false }
        return isSeparatorRow(lines[index + 1])
    }

    nonisolated private static func isSeparatorRow(_ line: String) -> Bool {
        let cells = tableCells(line)
        guard !cells.isEmpty else { return false }
        return cells.allSatisfy { cell in
            !cell.isEmpty && cell.allSatisfy { $0 == "-" || $0 == ":" } && cell.contains("-")
        }
    }

    /// Split a table row into trimmed cells, dropping the empties created by the
    /// leading/trailing pipes.
    nonisolated private static func tableCells(_ line: String) -> [String] {
        var trimmed = line.trimmingCharacters(in: .whitespaces)
        if trimmed.hasPrefix("|") { trimmed.removeFirst() }
        if trimmed.hasSuffix("|") { trimmed.removeLast() }
        return trimmed.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) }
    }

    private func tableView(_ rows: [[String]]) -> some View {
        let columns = rows.map(\.count).max() ?? 0
        return Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 6) {
            ForEach(Array(rows.enumerated()), id: \.offset) { rowIndex, row in
                GridRow {
                    ForEach(0..<columns, id: \.self) { col in
                        inline(col < row.count ? row[col] : "")
                            .font(.system(size: bodySize, weight: rowIndex == 0 ? .semibold : .regular))
                            .frame(maxWidth: .infinity, alignment: .leading)
                    }
                }
                if rowIndex == 0, columns > 0 {
                    Divider().gridCellColumns(columns)
                }
            }
        }
        .padding(10)
        .background(.quaternary.opacity(0.25), in: .rect(cornerRadius: 8))
        .overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(.quaternary, lineWidth: 1))
    }

    private func codeBlock(_ code: String) -> some View {
        ScrollView(.horizontal, showsIndicators: false) {
            Text(code)
                // Same point size as body/inline code, just monospaced — so code
                // doesn't shrink relative to the prose.
                .font(.system(size: bodySize, design: .monospaced))
                .textSelection(.enabled)
                .padding(8)
                .frame(maxWidth: .infinity, alignment: .leading)
        }
        .background(.quaternary.opacity(0.4), in: .rect(cornerRadius: 6))
    }

    private func textBlock(_ text: String) -> some View {
        VStack(alignment: .leading, spacing: 5) {
            ForEach(Array(text.components(separatedBy: "\n").enumerated()), id: \.offset) { _, raw in
                lineView(raw)
            }
        }
        // Breathing room between the visual lines of a single wrapped paragraph,
        // which the per-line VStack spacing above can't reach.
        .lineSpacing(4)
    }

    /// One prose line's classification: the exact string that gets inline-parsed — the cache key
    /// `prewarm` must match — paired with how it's styled. Single source of truth for both the
    /// live `lineView` and the off-main `prewarm`, so a warmed key can never drift from the key
    /// the layout later looks up (any drift and the warm entry would silently miss).
    private enum LineStyle {
        case blank
        case heading(content: String, scale: CGFloat, weight: Font.Weight)
        case bullet(content: String)
        case plain(content: String)

        /// The string handed to `inline(_:)`, or nil for a line that renders without a parse.
        var inlineContent: String? {
            switch self {
            case .blank: return nil
            case .heading(let c, _, _), .bullet(let c), .plain(let c): return c
            }
        }
    }

    /// Classify one raw prose line. Headings are checked before bullets (a heading marker wins),
    /// and the plain case keeps the *raw* line (not the trimmed one) exactly as the old cascade
    /// did — the inline parser preserves leading whitespace under `.inlineOnlyPreservingWhitespace`.
    nonisolated private static func classify(_ raw: String) -> LineStyle {
        let trimmed = raw.trimmingCharacters(in: .whitespaces)
        if trimmed.isEmpty { return .blank }
        // Headings scale relative to the base prose size so the hierarchy holds at any base and
        // never collapses to the body size.
        if trimmed.hasPrefix("### ") { return .heading(content: String(trimmed.dropFirst(4)), scale: 1.13, weight: .semibold) }
        if trimmed.hasPrefix("## ") { return .heading(content: String(trimmed.dropFirst(3)), scale: 1.28, weight: .bold) }
        if trimmed.hasPrefix("# ") { return .heading(content: String(trimmed.dropFirst(2)), scale: 1.5, weight: .bold) }
        if let bullet = bulletContent(trimmed) { return .bullet(content: bullet) }
        return .plain(content: raw)
    }

    @ViewBuilder
    private func lineView(_ raw: String) -> some View {
        switch Self.classify(raw) {
        case .blank:
            Color.clear.frame(height: 3)
        case .heading(let content, let scale, let weight):
            inline(content).font(.system(size: bodySize * scale, weight: weight))
        case .bullet(let content):
            HStack(alignment: .firstTextBaseline, spacing: 6) {
                Text("•").foregroundStyle(.secondary)
                inline(content)
            }
        case .plain(let content):
            inline(content)
        }
    }

    /// Returns the content after a `- `, `* `, `+ ` or `N. ` list marker, else nil.
    nonisolated private static func bulletContent(_ trimmed: String) -> String? {
        for marker in ["- ", "* ", "+ "] where trimmed.hasPrefix(marker) {
            return String(trimmed.dropFirst(marker.count))
        }
        // Numbered list: "12. text"
        let parts = trimmed.split(separator: " ", maxSplits: 1)
        if let first = parts.first, parts.count == 2,
            first.hasSuffix("."), Int(first.dropLast()) != nil {
            return String(parts[1])
        }
        return nil
    }

    private func inline(_ string: String) -> Text {
        Text(Self.attributedInline(string))
    }

    /// Inline-Markdown cache. `AttributedString(markdown:)` is the dominant per-row cost
    /// when a transcript first lays out — it runs once per prose line — and the same lines
    /// recur across re-renders and reopens, so memoize the parsed result. Independent of
    /// `bodySize` (callers apply the font), so the source string is a complete key.
    private final class InlineBox { let value: AttributedString; init(_ v: AttributedString) { self.value = v } }
    nonisolated(unsafe) private static let inlineCache: NSCache<NSString, InlineBox> = {
        let cache = NSCache<NSString, InlineBox>()
        cache.countLimit = 16384
        return cache
    }()

    nonisolated private static func attributedInline(_ string: String) -> AttributedString {
        let key = string as NSString
        if let hit = inlineCache.object(forKey: key) { return hit.value }
        let options = AttributedString.MarkdownParsingOptions(
            interpretedSyntax: .inlineOnlyPreservingWhitespace)
        // Fall back to the plain string on parse failure — `Text(AttributedString(string))`
        // renders identically to `Text(string)`, so callers see no behavioral change.
        let parsed = (try? AttributedString(markdown: string, options: options)) ?? AttributedString(string)
        inlineCache.setObject(InlineBox(parsed), forKey: key)
        return parsed
    }

    // MARK: - Pre-warming

    /// Populate the block and inline caches for `markdown` ahead of layout. `AttributedString(markdown:)`
    /// is the dominant per-row cost when a long transcript first lays out, and the eager `VStack`
    /// runs it for every prose line synchronously — right as the session is pushed and the tab bar
    /// is sliding away, which is what makes the load-in jitter. Calling this off the main thread
    /// (see `TranscriptList`) does that parsing in the background so the first layout hits ready
    /// results instead.
    ///
    /// Safe to call from any thread and redundantly: the caches are `NSCache` (thread-safe) and
    /// keyed only by the source string (independent of `bodySize`), so a warm value equals what
    /// the main thread would compute, and a repeat call is a cheap cache hit. Cooperatively
    /// cancellable — a huge transcript's warm loop bails the moment its owning task is cancelled.
    nonisolated static func prewarm(_ markdown: String) {
        for block in parse(markdown) {          // also warms the block cache
            if Task.isCancelled { return }
            switch block {
            case .code:
                break  // a code block renders via `Text(code)` — no inline parse to warm
            case .text(let text):
                for raw in text.components(separatedBy: "\n") {
                    if let content = classify(raw).inlineContent { _ = attributedInline(content) }
                }
            case .table(let rows):
                for row in rows where !Task.isCancelled {
                    for cell in row { _ = attributedInline(cell) }
                }
            }
        }
    }
}
