import AppKit
import Foundation

// MARK: - The clickable-run codec

/// The two kinds of thing a click in Preview can land on, encoded as URLs so that **AppKit's own
/// link hit-testing** is the whole of the click grammar (05-card-window.md ▸ Preview: "clicking the
/// rendered body selects text … and nothing else. The one interactive exception is task-list
/// checkboxes").
///
/// Encoding the checkbox as a link rather than tracking mouse events by hand is the point: a text
/// view already knows which character was clicked, already tells its delegate, already declines to
/// fire when the user was dragging out a selection, and already leaves every other character
/// selectable. Re-implementing that from `mouseDown` would be re-implementing text selection.
enum CardBodyLink {

    /// A scheme nothing on the system claims, so a checkbox can never be handed to `NSWorkspace`
    /// by a path that forgot to check.
    static let taskScheme = "x-lanework-task"

    /// `x-lanework-task:<byte offset>/<0 or 1>` — the offset the flip will aim at, and the state
    /// the user is looking at, which is what `BoardWriter.toggleTaskMarker` re-verifies against
    /// disk before it writes.
    static func task(offset: Int, isChecked: Bool) -> URL? {
        URL(string: "\(taskScheme):\(offset)/\(isChecked ? 1 : 0)")
    }

    /// The inverse. `nil` for every URL that is not one of ours — which is every ordinary link in
    /// a card body, and is how the delegate tells the two apart.
    static func parseTask(_ url: URL) -> (offset: Int, isChecked: Bool)? {
        guard url.scheme == taskScheme else { return nil }
        let parts = url.absoluteString.dropFirst(taskScheme.count + 1).split(separator: "/")
        guard parts.count == 2, let offset = Int(parts[0]), let state = Int(parts[1]) else { return nil }
        return (offset, state == 1)
    }
}

// MARK: - BodyMarkupRenderer

/// `BodyMarkup` → `NSAttributedString`: the drawing half of Preview, and **only** the drawing half.
///
/// Every decision that is not typography was already made in `BodyMarkup` — what is HTML, what is a
/// remote image, where a checkbox's byte lives — which is what keeps this file free of policy and
/// makes it the one part of Preview that is legitimately unverifiable by a unit test. What it does
/// own is the app's type scale: body text in the system body font (the same `CardWindowMetrics`
/// point size the whole window derives from), code in a monospaced face, and every indent, padding
/// and image cap a multiple of that one size, so Preview grows with the system text size like the
/// rest of the window (10-accessibility.md ▸ Text).
@MainActor
enum BodyMarkupRenderer {

    /// What a render needs beyond the markup itself: how big the body font is, and where the card's
    /// own folder is — the anchor every relative image and link resolves against (05 ▸ Preview).
    struct Context {
        var pointSize: CGFloat
        var cardFolder: URL?

        init(pointSize: CGFloat, cardFolder: URL?) {
            self.pointSize = pointSize
            self.cardFolder = cardFolder
        }
    }

    // MARK: Entry point

    static func attributedString(for markup: BodyMarkup, context: Context) -> NSAttributedString {
        let output = NSMutableAttributedString()
        var frame = Frame(context: context)
        append(markup.blocks, into: output, frame: &frame)
        return output
    }

    // The Edit surface's raw, monospaced rendering used to live here as `rawText`, while Edit was a
    // read-only placeholder sharing this file's substrate. It is now `MarkdownHighlighter`'s —
    // base attributes plus a span pass — and the promise it carried travelled with it: the
    // highlighter emits ranges, never a string, so "the text is the raw Markdown, character for
    // character" (05 ▸ Edit) is structural rather than a convention.

    // MARK: Block layout state

    /// Where in the block structure the walk currently is: which text blocks enclose it (quotes and
    /// table cells nest by *appending* to this), how far it is indented, and whether a list marker
    /// is owed to the next paragraph.
    private struct Frame {
        let context: Context
        var enclosing: [NSTextBlock] = []
        var indent: CGFloat = 0
        /// The bullet, number or checkbox that belongs on the first line of the next block — set
        /// when a list item starts and consumed by whichever block opens it.
        var pendingMarker: NSAttributedString?
    }

    private static func append(_ blocks: [BodyBlock], into output: NSMutableAttributedString, frame: inout Frame) {
        for block in blocks {
            append(block, into: output, frame: &frame)
        }
    }

    private static func append(_ block: BodyBlock, into output: NSMutableAttributedString, frame: inout Frame) {
        let size = frame.context.pointSize
        switch block {
        case let .heading(level, inlines, _):
            let style = paragraphStyle(frame: frame)
            style.paragraphSpacingBefore = size * (level <= 2 ? 1.0 : 0.75)
            style.paragraphSpacing = size * 0.25
            appendParagraph(
                inlines: inlines,
                base: [.font: headingFont(level: level, size: size), .foregroundColor: NSColor.labelColor],
                style: style,
                into: output,
                frame: &frame
            )

        case let .paragraph(inlines, _):
            let style = paragraphStyle(frame: frame)
            style.paragraphSpacing = size * 0.5
            appendParagraph(inlines: inlines, base: bodyAttributes(size), style: style, into: output, frame: &frame)

        case let .code(code, _, _):
            let style = paragraphStyle(frame: frame)
            style.paragraphSpacing = size * 0.5
            style.firstLineHeadIndent += CardWindowMetrics.previewPadding(bodyPointSize: size)
            style.headIndent += CardWindowMetrics.previewPadding(bodyPointSize: size)
            appendLiteral(
                trimmingTrailingNewlines(code),
                attributes: [
                    .font: monospacedFont(size),
                    .foregroundColor: NSColor.labelColor,
                    .backgroundColor: NSColor.quaternarySystemFill
                ],
                style: style,
                into: output,
                frame: &frame
            )

        case let .html(raw, _):
            // Verbatim, and styled as what it is: text the author typed, not a document the app
            // interpreted (05 ▸ Preview; 00-vision.md's no-web-tech stance).
            let style = paragraphStyle(frame: frame)
            style.paragraphSpacing = size * 0.5
            appendLiteral(
                trimmingTrailingNewlines(raw),
                attributes: [
                    .font: monospacedFont(size),
                    .foregroundColor: NSColor.secondaryLabelColor,
                    .backgroundColor: NSColor.quaternarySystemFill
                ],
                style: style,
                into: output,
                frame: &frame
            )

        case .thematicBreak:
            let rule = NSTextBlock()
            rule.setWidth(1, type: .absoluteValueType, for: .border, edge: .minY)
            rule.setBorderColor(.separatorColor)
            let style = paragraphStyle(frame: frame)
            style.textBlocks = frame.enclosing + [rule]
            style.paragraphSpacingBefore = size * 0.5
            style.paragraphSpacing = size * 0.5
            // A near-empty line carrying the rule: the border is the mark, the character is only
            // something for the layout manager to hang it on.
            output.append(NSAttributedString(string: "\u{00A0}\n", attributes: [
                .font: NSFont.systemFont(ofSize: 1),
                .paragraphStyle: style
            ]))

        case let .quote(blocks, _):
            let bar = NSTextBlock()
            bar.setWidth(size * 0.2, type: .absoluteValueType, for: .border, edge: .minX)
            bar.setBorderColor(.tertiaryLabelColor)
            bar.setWidth(CardWindowMetrics.previewPadding(bodyPointSize: size), type: .absoluteValueType, for: .padding, edge: .minX)

            var inner = frame
            inner.enclosing.append(bar)
            append(blocks, into: output, frame: &inner)
            frame.pendingMarker = inner.pendingMarker

        case let .list(list, _):
            append(list, into: output, frame: &frame)

        case let .table(table, _):
            append(table, into: output, frame: &frame)
        }
    }

    // MARK: Lists

    private static func append(_ list: BodyList, into output: NSMutableAttributedString, frame: inout Frame) {
        let size = frame.context.pointSize
        for (offset, item) in list.items.enumerated() {
            var inner = frame
            inner.indent += CardWindowMetrics.previewIndent(bodyPointSize: size)
            inner.pendingMarker = marker(for: item, list: list, number: list.start + offset, size: size)

            if item.blocks.isEmpty {
                // An empty item still has a marker to show — a bare `- [ ]` is a checkbox the user
                // has not written a label for yet, and swallowing it would lose a live control.
                let style = paragraphStyle(frame: inner)
                appendParagraph(inlines: [], base: bodyAttributes(size), style: style, into: output, frame: &inner)
            } else {
                append(item.blocks, into: output, frame: &inner)
            }
        }
    }

    /// A list item's leading run: a checkbox when the item is a task, otherwise a bullet or its
    /// number.
    ///
    /// The checkbox carries a `.link` attribute — see `CardBodyLink` — which is what makes it the
    /// one clickable thing in an otherwise read-only surface. When the parse could not locate its
    /// source byte the box still renders, without the link: inert rather than absent, because the
    /// box is content the user wrote.
    private static func marker(
        for item: BodyListItem,
        list: BodyList,
        number: Int,
        size: CGFloat
    ) -> NSAttributedString {
        guard let task = item.task else {
            let text = list.isOrdered ? "\(number)." : "•"
            return NSAttributedString(string: text + "\t", attributes: [
                .font: NSFont.systemFont(ofSize: size),
                .foregroundColor: NSColor.secondaryLabelColor
            ])
        }

        var attributes: [NSAttributedString.Key: Any] = [
            .font: NSFont.systemFont(ofSize: size * 1.1),
            .foregroundColor: task.isChecked ? NSColor.controlAccentColor : NSColor.secondaryLabelColor
        ]
        if let offset = task.markerOffset,
           let url = CardBodyLink.task(offset: offset, isChecked: task.isChecked) {
            attributes[.link] = url
            attributes[.cursor] = NSCursor.pointingHand
        }

        let box = NSMutableAttributedString(string: task.isChecked ? "\u{2611}" : "\u{2610}", attributes: attributes)
        box.append(NSAttributedString(string: "\t", attributes: [.font: NSFont.systemFont(ofSize: size)]))
        return box
    }

    // MARK: Tables

    /// A GFM table as an `NSTextTable`, whose automatic layout algorithm **is** the "columns sized
    /// to contents with the browser sizing rule" the design asks for — the same rule, implemented
    /// once by AppKit rather than approximated here with measured tab stops.
    ///
    /// This is also the reason the whole surface runs on TextKit 1 (`CardBodySurfaceView`):
    /// `NSTextTable` is a TextKit 1 construct, and a table drawn with tab stops would lose both
    /// its rules and its wrapping.
    private static func append(_ table: BodyTable, into output: NSMutableAttributedString, frame: inout Frame) {
        let columns = max(1, table.columnCount)

        let textTable = NSTextTable()
        textTable.numberOfColumns = columns
        textTable.layoutAlgorithm = .automaticLayoutAlgorithm
        textTable.collapsesBorders = true
        textTable.hidesEmptyCells = false

        var row = 0
        appendRow(table.header, isHeader: true, row: &row, of: textTable, table: table, into: output, frame: &frame)
        for cells in table.rows {
            appendRow(cells, isHeader: false, row: &row, of: textTable, table: table, into: output, frame: &frame)
        }
    }

    private static func appendRow(
        _ cells: [BodyTableCell],
        isHeader: Bool,
        row: inout Int,
        of textTable: NSTextTable,
        table: BodyTable,
        into output: NSMutableAttributedString,
        frame: inout Frame
    ) {
        guard !cells.isEmpty else { return }
        let size = frame.context.pointSize
        let padding = CardWindowMetrics.previewPadding(bodyPointSize: size)

        var column = 0
        for cell in cells where column < max(1, table.columnCount) {
            let span = max(1, min(cell.colspan, max(1, table.columnCount) - column))
            let block = NSTextTableBlock(
                table: textTable,
                startingRow: row,
                rowSpan: 1,
                startingColumn: column,
                columnSpan: span
            )
            block.setBorderColor(.separatorColor)
            block.setWidth(1, type: .absoluteValueType, for: .border)
            block.setWidth(padding, type: .absoluteValueType, for: .padding)
            if isHeader { block.backgroundColor = .quaternarySystemFill }

            let style = paragraphStyle(frame: frame)
            style.textBlocks = frame.enclosing + [block]
            style.alignment = alignment(table.alignments.indices.contains(column) ? table.alignments[column] : .unspecified)
            // Cells never inherit the surrounding indent: the table block already places them.
            style.firstLineHeadIndent = 0
            style.headIndent = 0

            var base = bodyAttributes(size)
            if isHeader { base[.font] = NSFont.systemFont(ofSize: size, weight: .semibold) }

            var cellFrame = frame
            cellFrame.pendingMarker = nil
            appendParagraph(inlines: cell.inlines, base: base, style: style, into: output, frame: &cellFrame)

            column += span
        }
        row += 1
    }

    private static func alignment(_ alignment: BodyTable.Alignment) -> NSTextAlignment {
        switch alignment {
        case .unspecified, .leading: .natural
        case .center: .center
        case .trailing: .right
        }
    }

    // MARK: Paragraph assembly

    private static func appendParagraph(
        inlines: [BodyInline],
        base: [NSAttributedString.Key: Any],
        style: NSMutableParagraphStyle,
        into output: NSMutableAttributedString,
        frame: inout Frame
    ) {
        let paragraph = NSMutableAttributedString()
        if let marker = frame.pendingMarker {
            paragraph.append(marker)
            frame.pendingMarker = nil
        }
        append(inlines, into: paragraph, base: base, frame: frame)
        paragraph.append(NSAttributedString(string: "\n", attributes: base))
        paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length))
        output.append(paragraph)
    }

    /// A literal block — code, HTML — where the text's own newlines are content and no inline
    /// parsing ever ran over it.
    private static func appendLiteral(
        _ text: String,
        attributes: [NSAttributedString.Key: Any],
        style: NSMutableParagraphStyle,
        into output: NSMutableAttributedString,
        frame: inout Frame
    ) {
        let paragraph = NSMutableAttributedString()
        if let marker = frame.pendingMarker {
            paragraph.append(marker)
            frame.pendingMarker = nil
        }
        paragraph.append(NSAttributedString(string: text + "\n", attributes: attributes))
        paragraph.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: paragraph.length))
        output.append(paragraph)
    }

    private static func paragraphStyle(frame: Frame) -> NSMutableParagraphStyle {
        let style = NSMutableParagraphStyle()
        style.textBlocks = frame.enclosing
        style.firstLineHeadIndent = frame.indent
        style.headIndent = frame.indent
        style.lineSpacing = frame.context.pointSize * 0.15
        // One tab stop, where a list item's text begins — which is what turns "marker, tab, text"
        // into a hanging indent rather than a ragged one.
        style.tabStops = [NSTextTab(textAlignment: .left, location: frame.indent, options: [:])]
        style.defaultTabInterval = CardWindowMetrics.previewIndent(bodyPointSize: frame.context.pointSize)
        return style
    }

    // MARK: Inlines

    /// The inline traits carried down the recursion — bold and italic compose, so they are state
    /// rather than a font chosen at each node.
    private struct InlineTraits {
        var isBold = false
        var isItalic = false
        var isStruck = false
    }

    private static func append(
        _ inlines: [BodyInline],
        into output: NSMutableAttributedString,
        base: [NSAttributedString.Key: Any],
        frame: Frame,
        traits: InlineTraits = InlineTraits()
    ) {
        for inline in inlines {
            append(inline, into: output, base: base, frame: frame, traits: traits)
        }
    }

    private static func append(
        _ inline: BodyInline,
        into output: NSMutableAttributedString,
        base: [NSAttributedString.Key: Any],
        frame: Frame,
        traits: InlineTraits
    ) {
        let size = frame.context.pointSize
        switch inline {
        case let .text(text):
            output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits)))

        case let .emphasis(children):
            var inner = traits
            inner.isItalic = true
            append(children, into: output, base: base, frame: frame, traits: inner)

        case let .strong(children):
            var inner = traits
            inner.isBold = true
            append(children, into: output, base: base, frame: frame, traits: inner)

        case let .strikethrough(children):
            var inner = traits
            inner.isStruck = true
            append(children, into: output, base: base, frame: frame, traits: inner)

        case let .code(code):
            var attributes = attributes(base, traits: traits)
            attributes[.font] = monospacedFont(size)
            attributes[.backgroundColor] = NSColor.quaternarySystemFill
            output.append(NSAttributedString(string: code, attributes: attributes))

        case let .html(raw):
            // Same rule as the block form, one line down: `<br>` is four characters and a `>`.
            var attributes = attributes(base, traits: traits)
            attributes[.font] = monospacedFont(size)
            attributes[.foregroundColor] = NSColor.secondaryLabelColor
            attributes[.backgroundColor] = NSColor.quaternarySystemFill
            output.append(NSAttributedString(string: raw, attributes: attributes))

        case .lineBreak, .softBreak:
            // A soft break is a newline in the source and a space to the reader; a hard break is a
            // line of its own. `NSAttributedString` has no soft-wrap character, so the difference
            // is exactly this.
            let text = if case .lineBreak = inline { "\u{2028}" } else { " " }
            output.append(NSAttributedString(string: text, attributes: attributes(base, traits: traits)))

        case let .link(target, children):
            var attributes = attributes(base, traits: traits)
            if let url = target.resolve(inCardFolder: frame.context.cardFolder) {
                attributes[.link] = url
                attributes[.foregroundColor] = NSColor.linkColor
                attributes[.underlineStyle] = NSUnderlineStyle.single.rawValue
            }
            let start = output.length
            append(children, into: output, base: base, frame: frame, traits: traits)
            if output.length == start {
                output.append(NSAttributedString(string: target.text, attributes: attributes))
            } else {
                output.addAttributes(attributes, range: NSRange(location: start, length: output.length - start))
            }

        case let .image(image):
            output.append(rendered(image, frame: frame, base: base, traits: traits))
        }
    }

    /// An image: **loaded from the card's folder, or a chip that says what was not loaded.**
    ///
    /// The fork is `BodyTarget`'s, made in the model; what happens here is only the consequence.
    /// Nothing in this function opens a socket — a `.absolute` target is never handed to a loader
    /// at all, which is how "Preview does no networking" is enforced rather than merely intended
    /// (05 ▸ Preview).
    private static func rendered(
        _ image: BodyImage,
        frame: Frame,
        base: [NSAttributedString.Key: Any],
        traits: InlineTraits
    ) -> NSAttributedString {
        let size = frame.context.pointSize
        if case .relative = image.target,
           let url = image.target.resolve(inCardFolder: frame.context.cardFolder),
           let loaded = NSImage(contentsOf: url) {
            let attachment = NSTextAttachment()
            attachment.image = loaded
            let cap = CardWindowMetrics.previewImageMaximumWidth(bodyPointSize: size)
            let scale = loaded.size.width > cap && loaded.size.width > 0 ? cap / loaded.size.width : 1
            attachment.bounds = CGRect(
                x: 0,
                y: 0,
                width: (loaded.size.width * scale).rounded(),
                height: (loaded.size.height * scale).rounded()
            )
            let string = NSMutableAttributedString(attachment: attachment)
            // The alt text as the tool tip: it is the one place an inline attachment can say what
            // it is, and it is also what the text view reads out when nothing else describes it.
            string.addAttribute(
                .toolTip,
                value: image.alt.isEmpty ? image.target.text : image.alt,
                range: NSRange(location: 0, length: string.length)
            )
            return string
        }

        // The quiet placeholder chip: alt text where there is any, the URL where there is not, so
        // the reader always learns *what* is not being shown.
        let label = image.alt.isEmpty ? image.target.text : image.alt
        var attributes = attributes(base, traits: traits)
        attributes[.font] = NSFont.systemFont(ofSize: size * 0.9)
        attributes[.foregroundColor] = NSColor.secondaryLabelColor
        attributes[.backgroundColor] = NSColor.quaternarySystemFill
        return NSAttributedString(string: " \u{1F5BC}\u{FE0E} \(label) ", attributes: attributes)
    }

    // MARK: Fonts

    private static func attributes(
        _ base: [NSAttributedString.Key: Any],
        traits: InlineTraits
    ) -> [NSAttributedString.Key: Any] {
        var attributes = base
        if let font = base[.font] as? NSFont {
            attributes[.font] = styled(font, traits: traits)
        }
        if traits.isStruck {
            attributes[.strikethroughStyle] = NSUnderlineStyle.single.rawValue
        }
        return attributes
    }

    private static func styled(_ font: NSFont, traits: InlineTraits) -> NSFont {
        var mask: NSFontTraitMask = []
        if traits.isBold { mask.insert(.boldFontMask) }
        if traits.isItalic { mask.insert(.italicFontMask) }
        guard !mask.isEmpty else { return font }
        return NSFontManager.shared.convert(font, toHaveTrait: mask)
    }

    private static func bodyAttributes(_ size: CGFloat) -> [NSAttributedString.Key: Any] {
        [.font: NSFont.systemFont(ofSize: size), .foregroundColor: NSColor.labelColor]
    }

    /// The heading ladder, in multiples of the body size so it scales with everything else. Levels
    /// past four stop growing and stay merely emphasized, which is what they are.
    private static func headingFont(level: Int, size: CGFloat) -> NSFont {
        let scale: CGFloat = switch level {
        case 1: 1.8
        case 2: 1.5
        case 3: 1.25
        case 4: 1.1
        default: 1.0
        }
        return NSFont.systemFont(ofSize: (size * scale).rounded(), weight: level <= 2 ? .bold : .semibold)
    }

    private static func monospacedFont(_ size: CGFloat) -> NSFont {
        NSFont.monospacedSystemFont(ofSize: (size * 0.95).rounded(), weight: .regular)
    }

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