import SwiftUI

/// A card body, rendered — the phone's counterpart to the Mac's `BodyMarkupRenderer`, and the
/// anticipated surface `project.yml`'s dependency comment names: "`BodyMarkup` imports `Markdown`,
/// so the dependency rides along even though the MVP has no preview surface yet." This is that
/// surface arriving.
///
/// **Same model, different substrate, same semantics where it matters.** `BodyMarkup.parse(_:)` —
/// the shared parse the Mac's Preview also runs — already made every decision that is not
/// typography: HTML is literal text, an image is never fetched, a task's live offset lives on the
/// model rather than the render. What is left here is exactly what was left for
/// `BodyMarkupRenderer`: turning a `[BodyBlock]` into pixels, on a different toolkit (SwiftUI
/// `Text`/`AttributedString` rather than `NSTextView`/`NSTextTable`), and read-only — there is no
/// tap-to-toggle checkbox this pass; a static SF Symbol stands in, and flipping it live is a
/// separate future decision (see `TaskMarkerView` below).
///
/// **Why a block renderer at all, and not the inline-only `AttributedString(markdown:)` shim this
/// replaces.** That shim split on blank lines and parsed each paragraph as inline Markdown, which
/// covers emphasis, links, and code spans but nothing block-shaped: a card body with a heading, a
/// fenced code sample, a list, or a table rendered as an indistinguishable paragraph of stray
/// punctuation. `BodyMarkup` already parses the full block tree — the walk this view does is the
/// one the shim's own doc comment predicted would be "no simpler than doing the walk ourselves",
/// except now there is a real block tree to walk instead of a hand-split string.
struct CardBodyView: View {
    private let blocks: [BodyBlock]

    init(body: String) {
        blocks = BodyMarkup.parse(body).blocks
    }

    var body: some View {
        if blocks.isEmpty {
            Text("No description")
                .foregroundStyle(.secondary)
        } else {
            BlockListView(blocks: blocks, spacing: 14)
        }
    }
}

// MARK: - Block layout

/// A run of sibling blocks, stacked top to bottom — the shape every block-container case in
/// `BodyBlock` recurses through: the document's own top level, a list item's content, a quote's
/// nested blocks, a table cell. One view handles all of them, which is what makes the recursion
/// (quotes inside quotes, lists inside lists) fall out for free rather than needing a depth
/// parameter threaded everywhere.
private struct BlockListView: View {
    let blocks: [BodyBlock]
    var spacing: CGFloat = 10

    var body: some View {
        VStack(alignment: .leading, spacing: spacing) {
            ForEach(Array(blocks.enumerated()), id: \.offset) { _, block in
                BlockView(block: block)
            }
        }
    }
}

private struct BlockView: View {
    let block: BodyBlock

    var body: some View {
        switch block {
        case let .heading(level, inlines, _):
            Text(InlineText.render(inlines, font: Self.headingFont(level: level)))
                .padding(.top, level <= 2 ? 4 : 0)

        case let .paragraph(inlines, _):
            Text(InlineText.render(inlines))

        case let .code(code, _, _):
            LiteralBlockView(text: Self.trimmingTrailingNewlines(code), color: .primary)

        case let .html(raw, _):
            // Verbatim, same rule the model itself makes: `<b>x</b>` is code-styled characters,
            // never a rendered bold. Dimmed relative to code to read as "the author's markup",
            // not "content" — the Mac renderer's own choice for this case.
            LiteralBlockView(text: Self.trimmingTrailingNewlines(raw), color: .secondary)

        case .thematicBreak:
            Divider()
                .padding(.vertical, 4)

        case let .quote(blocks, _):
            QuoteView(blocks: blocks)

        case let .list(list, _):
            ListView(list: list)

        case let .table(table, _):
            TableView(table: table)
        }
    }

    /// The heading ladder the spec asks for: a three-step title2/title3/headline run rather than
    /// the Mac's continuous four-step scale, because that is the whole of SwiftUI's built-in text
    /// style vocabulary above body weight — anything past level 3 stays at `.headline` rather than
    /// inventing a fourth size nothing else on the screen uses.
    fileprivate static func headingFont(level: Int) -> Font {
        switch level {
        case 1: .title2.weight(.bold)
        case 2: .title3.weight(.bold)
        default: .headline
        }
    }

    fileprivate static func trimmingTrailingNewlines(_ text: String) -> String {
        var text = text
        while text.hasSuffix("\n") || text.hasSuffix("\r") { text.removeLast() }
        return text
    }
}

/// A fenced/indented code block or a literal HTML block: monospaced, in a soft rounded container,
/// scrolling sideways rather than wrapping — the shape a code sample or a raw tag soup needs to
/// stay legible instead of ricocheting across narrow phone-width lines.
private struct LiteralBlockView: View {
    let text: String
    let color: Color

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            Text(text)
                .font(.system(.callout, design: .monospaced))
                .foregroundStyle(color)
                .padding(10)
        }
        .background(Color(.systemGray6), in: RoundedRectangle(cornerRadius: 8))
    }
}

/// A block quote: a leading accent bar beside its nested blocks, recursing through `BlockListView`
/// for whatever those blocks are — including another quote, which is what makes a quote-in-a-quote
/// read as two bars rather than something this view has to special-case.
private struct QuoteView: View {
    let blocks: [BodyBlock]

    var body: some View {
        HStack(alignment: .top, spacing: 10) {
            Capsule()
                .fill(Color.secondary.opacity(0.35))
                .frame(width: 3)
            BlockListView(blocks: blocks, spacing: 8)
        }
    }
}

// MARK: - Lists

/// A bullet, ordered, or task list. Nested lists are not a case this view special-cases either:
/// `BodyListItem.blocks` recurses through `BlockListView` just like a quote's blocks do, and
/// because a nested list lands *inside* the marker/content `HStack` of the item that owns it, the
/// indentation the spec asks for ("nested lists indent") falls out of that layout rather than
/// needing a manually tracked depth.
private struct ListView: View {
    let list: BodyList

    var body: some View {
        VStack(alignment: .leading, spacing: 6) {
            ForEach(Array(list.items.enumerated()), id: \.offset) { offset, item in
                ListItemView(item: item, marker: marker(for: item, index: offset))
            }
        }
    }

    private func marker(for item: BodyListItem, index: Int) -> ListMarker {
        if let task = item.task { return .task(isChecked: task.isChecked) }
        return list.isOrdered ? .number(list.start + index) : .bullet
    }
}

private enum ListMarker {
    case bullet
    case number(Int)
    case task(isChecked: Bool)
}

private struct ListItemView: View {
    let item: BodyListItem
    let marker: ListMarker

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: 8) {
            markerView
            if item.blocks.isEmpty {
                // A bare `- [ ]` with no label yet is still a checkbox the user wrote — keep the
                // marker on screen rather than collapsing the row to nothing, the same posture
                // `BodyMarkupRenderer.append(_:BodyList:...)` takes on the Mac.
                Text(" ")
            } else {
                BlockListView(blocks: item.blocks, spacing: 8)
            }
        }
    }

    @ViewBuilder
    private var markerView: some View {
        switch marker {
        case .bullet:
            Text("•")
                .foregroundStyle(.secondary)
                .accessibilityHidden(true)

        case let .number(number):
            Text("\(number).")
                .foregroundStyle(.secondary)
                .monospacedDigit()
                .accessibilityHidden(true)

        case let .task(isChecked):
            TaskMarkerView(isChecked: isChecked)
        }
    }
}

/// A task-list checkbox, drawn but not driven: `checkmark.square.fill` when checked, `square`
/// otherwise, both in a secondary tint. The Mac's Preview makes this glyph clickable — a `.link`
/// URL that round-trips through `CardBodyLink` to flip the one byte `BodyTask.markerOffset` names
/// — but that write path (and the re-verification `BoardWriter.toggleTaskMarker` does against
/// disk before committing it) is deliberately not part of this pass: `CardDetailScreen` is a
/// read-only screen with no session write of its own, and giving one glyph inside it a live write
/// would be a bigger decision than this pass is scoped to make. A future pass that wants tappable
/// checkboxes here can lean on the same model-carried offset; nothing about `BodyTask` needs to
/// change to support it.
private struct TaskMarkerView: View {
    let isChecked: Bool

    var body: some View {
        Image(systemName: isChecked ? "checkmark.square.fill" : "square")
            .foregroundStyle(.secondary)
            .accessibilityLabel(isChecked ? "Completed task" : "Incomplete task")
    }
}

// MARK: - Tables

/// A GFM table as a `Grid`: per-column alignment from `BodyTable.alignments`, a bold header row,
/// and a colspan handled with `.gridCellColumns` — `Grid`'s own multi-column span primitive, which
/// is to this layout what `NSTextTable`'s automatic sizing is to the Mac's: the one built-in
/// mechanism that makes a hand-rolled tab-stop table unnecessary. Wrapped in a horizontal
/// `ScrollView` because a table sized to its content is routinely wider than a phone screen.
private struct TableView: View {
    let table: BodyTable

    private var columnCount: Int { max(1, table.columnCount) }

    var body: some View {
        ScrollView(.horizontal, showsIndicators: false) {
            Grid(alignment: .topLeading, horizontalSpacing: 14, verticalSpacing: 8) {
                row(table.header, isHeader: true)
                Divider().gridCellColumns(columnCount)
                ForEach(Array(table.rows.enumerated()), id: \.offset) { _, cells in
                    row(cells, isHeader: false)
                }
            }
            .padding(.vertical, 2)
        }
    }

    @ViewBuilder
    private func row(_ cells: [BodyTableCell], isHeader: Bool) -> some View {
        GridRow {
            ForEach(positioned(cells), id: \.column) { positioned in
                cellView(positioned.cell, column: positioned.column, span: positioned.span, isHeader: isHeader)
            }
        }
    }

    /// Cells with their starting grid column and clamped span, walked left to right so a colspan
    /// earlier in the row shifts every cell after it — the same bookkeeping `BodyTableRenderer`'s
    /// `NSTextTableBlock(startingColumn:columnSpan:)` construction does on the Mac, done here in
    /// terms `Grid` understands. A span that would run past the table's own column count is
    /// clamped rather than trusted — "handled at least degenerately, don't crash" — and a row with
    /// more cells than there is room for simply stops placing them.
    private func positioned(_ cells: [BodyTableCell]) -> [(cell: BodyTableCell, column: Int, span: Int)] {
        var column = 0
        var result: [(cell: BodyTableCell, column: Int, span: Int)] = []
        for cell in cells where column < columnCount {
            let span = max(1, min(cell.colspan, columnCount - column))
            result.append((cell, column, span))
            column += span
        }
        return result
    }

    @ViewBuilder
    private func cellView(_ cell: BodyTableCell, column: Int, span: Int, isHeader: Bool) -> some View {
        let alignment = table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified
        Text(InlineText.render(cell.inlines, font: isHeader ? .body.weight(.semibold) : .body))
            .multilineTextAlignment(textAlignment(alignment))
            .gridColumnAlignment(horizontalAlignment(alignment))
            .gridCellColumns(span)
    }

    private func horizontalAlignment(_ alignment: BodyTable.Alignment) -> HorizontalAlignment {
        switch alignment {
        case .unspecified, .leading: .leading
        case .center: .center
        case .trailing: .trailing
        }
    }

    private func textAlignment(_ alignment: BodyTable.Alignment) -> TextAlignment {
        switch alignment {
        case .unspecified, .leading: .leading
        case .center: .center
        case .trailing: .trailing
        }
    }
}

// MARK: - Inlines

/// `[BodyInline]` → one `AttributedString`, the whole of the phone's inline vocabulary in a single
/// recursive walk. Traits (bold/italic/strikethrough) are carried down as state and composed at
/// the leaves rather than each node picking its own font, the same reasoning
/// `BodyMarkupRenderer.InlineTraits` gives on the Mac: `**bold _and italic_**` nests two inline
/// cases around one run of text, and the run needs to know about both by the time it is drawn.
private enum InlineText {

    struct Traits {
        var isBold = false
        var isItalic = false
        var isStruck = false
    }

    static func render(_ inlines: [BodyInline], font: Font = .body) -> AttributedString {
        var result = AttributedString()
        append(inlines, into: &result, font: font, traits: Traits())
        return result
    }

    private static func append(
        _ inlines: [BodyInline],
        into result: inout AttributedString,
        font: Font,
        traits: Traits
    ) {
        for inline in inlines {
            append(inline, into: &result, font: font, traits: traits)
        }
    }

    private static func append(
        _ inline: BodyInline,
        into result: inout AttributedString,
        font: Font,
        traits: Traits
    ) {
        switch inline {
        case let .text(text):
            result += run(text, font: font, traits: traits)

        case let .emphasis(children):
            var inner = traits
            inner.isItalic = true
            append(children, into: &result, font: font, traits: inner)

        case let .strong(children):
            var inner = traits
            inner.isBold = true
            append(children, into: &result, font: font, traits: inner)

        case let .strikethrough(children):
            var inner = traits
            inner.isStruck = true
            append(children, into: &result, font: font, traits: inner)

        case let .code(code):
            var span = AttributedString(code)
            span.font = .system(.body, design: .monospaced)
            span.backgroundColor = Color(.systemGray5)
            if traits.isStruck { span.strikethroughStyle = .single }
            result += span

        case let .html(raw):
            // Same rule as the block form: what the author typed is what the reader sees, styled
            // to read as markup rather than prose.
            var span = AttributedString(raw)
            span.font = .system(.body, design: .monospaced)
            span.foregroundColor = .secondary
            span.backgroundColor = Color(.systemGray5)
            result += span

        case .lineBreak:
            // A hard break: a line of its own.
            result += run("\n", font: font, traits: traits)

        case .softBreak:
            // An ordinary newline in the source, a space to the reader — the same fold the Mac
            // renderer performs (`NSAttributedString` has no soft-wrap character either; the
            // difference from a hard break there is exactly this same one-space-versus-a-line
            // choice, made in `AttributedString` terms here instead of `NSAttributedString`'s).
            result += run(" ", font: font, traits: traits)

        case let .link(target, children):
            var span = AttributedString()
            append(children, into: &span, font: font, traits: traits)
            if span.characters.isEmpty {
                span = run(target.text, font: font, traits: traits)
            }
            // Only a scheme-carrying destination becomes a live `.link` — a relative path (a
            // sibling file in the card's own folder, the Mac's "open with its default app" case)
            // has nothing to resolve against here: this initializer takes only the body string,
            // no card folder, and a phone app has no "open this arbitrary file with its default
            // app" affordance to hand it to regardless. It still reads as the label the author
            // wrote; it just is not tappable, rather than guessing at a destination.
            if case .absolute = target, let url = URL(string: target.text) {
                span.link = url
                // The children were appended with an explicit `.primary` foreground (every run
                // gets one), and an explicit colour beats the tint `Text` would otherwise paint a
                // `.link` run with — clearing it is what lets a live link *look* live instead of
                // rendering as plain body text that happens to respond to a tap.
                span.foregroundColor = nil
            }
            result += span

        case let .image(image):
            // No image loading this pass, on either fork of `BodyTarget` — the alt text (or the
            // raw destination, if the author left no alt) stands in behind a paperclip glyph, so
            // the reader learns what is not being shown without this view touching the network or
            // the filesystem.
            let label = image.alt.isEmpty ? image.target.text : image.alt
            var span = AttributedString("\u{1F4CE} \(label)")
            span.font = font
            span.foregroundColor = .secondary
            result += span
        }
    }

    private static func run(_ text: String, font: Font, traits: Traits) -> AttributedString {
        var span = AttributedString(text)
        var styledFont = font
        if traits.isBold { styledFont = styledFont.bold() }
        if traits.isItalic { styledFont = styledFont.italic() }
        span.font = styledFont
        span.foregroundColor = .primary
        if traits.isStruck { span.strikethroughStyle = .single }
        return span
    }
}
