import AppKit
import Foundation

// MARK: - MarkdownHighlighter

/// The Edit editor's **lightweight Markdown syntax highlighting** (05-card-window.md ▸ Edit:
/// "headings emphasized, bold/italic rendered as such, code tinted, link targets and structural
/// markers dimmed").
///
/// ### It emits ranges, and that is the whole safety argument
///
/// "Highlighting is presentation only: the text is the raw Markdown, character for character — no
/// hidden transforms, no smart substitutions" (05 ▸ Edit). A highlighter that returned a string
/// could break that promise; one that returns `[Span]` — offsets into the text it was handed —
/// structurally cannot. `apply(_:to:pointSize:)` is the only part that touches a text storage, and it
/// calls nothing but `setAttributes`/`addAttribute`.
///
/// ### Why a line scanner rather than a swift-markdown re-parse
///
/// Preview parses with swift-markdown (`BodyMarkup`) because it renders *structure* — tables, nested
/// quotes, list nesting — and structure is what a parser is for. The editor needs something
/// different, and the difference is decisive:
///
/// - **It runs on every keystroke.** A full CommonMark parse per character, on the main actor,
///   buys a document tree that is thrown away immediately; a single pass of a handful of
///   line-anchored regexes is what the job actually needs.
/// - **The text is usually invalid.** Half the time an editor's buffer holds `**bo` or `[label](`,
///   because the user is mid-word. A parser resolves those to *paragraph text*, so emphasis would
///   pop into existence on the closing asterisk and structure would flicker with every keystroke.
///   A scanner highlights what is there: the delimiter dims as it is typed, and the run styles when
///   it closes.
/// - **Delimiters are the point here.** 05 asks for the markers themselves to be dimmed, and a
///   parsed tree deliberately discards them — swift-markdown gives the emphasized *content*, not the
///   asterisks around it.
///
/// The cost is that the scanner is line-local: it knows fenced code blocks (a running state), and
/// nothing else spanning lines. A `**bold` opened on one line and closed on the next is not styled,
/// which is a fair trade for highlighting that never lies about half-typed markup and never re-parses
/// a document to draw one line of it.
///
/// ### Scope of a pass
///
/// A pass rebuilds the whole body's attributes. That is honest for the input this app has — a card
/// body is a card, not a book — and it is what keeps the fenced-code state correct without tracking
/// which line invalidated which: the state is recomputed from the top, every time, in one linear
/// walk over the text.
enum MarkdownHighlighter {

    // MARK: - Vocabulary

    /// What a run of characters *is* — the five things 05 names, plus the structural markers it asks
    /// to have dimmed.
    enum Token: Equatable, Sendable {
        /// A heading's text (`# ` already excluded — that is `.structural`).
        case heading(level: Int)
        /// `**bold**`'s content.
        case strong
        /// `*italic*`'s content.
        case emphasis
        /// `~~struck~~`'s content.
        case strikethrough
        /// An inline code span's content, or a fenced/indented code line.
        case code
        /// A list's bullet, number, or task checkbox — the marker itself.
        case listMarker
        /// A link or image's visible text.
        case linkText
        /// A link or image's target — "link targets … dimmed" (05 ▸ Edit).
        case linkTarget
        /// Every delimiter: `#`, `**`, backticks, brackets, parens, `>`, a thematic break.
        case structural
    }

    /// One run of text and what it is. `range` is in **UTF-16 units** (`NSRange`), because its only
    /// consumer is `NSTextStorage` — the same reason `BodyMarkup` uses UTF-8 byte offsets and this
    /// does not: each carries the offsets its own consumer speaks.
    struct Span: Equatable, Sendable {
        var range: NSRange
        var token: Token
    }

    // MARK: - The scan

    /// Every styled run in `text`, in ascending order and never overlapping.
    ///
    /// Pure, total, and allocation-light: any string is valid input, including one that is malformed
    /// Markdown in every way at once, and the result is always a partition-compatible set of ranges
    /// inside `text`.
    static func spans(in text: String) -> [Span] {
        let ns = text as NSString
        guard ns.length > 0 else { return [] }

        var spans: [Span] = []
        var fence: String?

        forEachLine(in: ns) { line in
            if let open = fence {
                // Inside a fenced block every line is code, and only the matching fence closes it.
                if let closing = fenceRun(in: ns, line: line), closing.marker == open {
                    spans.append(Span(range: closing.range, token: .structural))
                    fence = nil
                } else {
                    spans.append(Span(range: line, token: .code))
                }
                return
            }

            if let opening = fenceRun(in: ns, line: line) {
                spans.append(Span(range: opening.range, token: .structural))
                // The info string (` ```swift `) is part of the fence, not of the code.
                let info = NSRange(
                    location: opening.range.upperBound,
                    length: line.upperBound - opening.range.upperBound
                )
                if info.length > 0 { spans.append(Span(range: info, token: .linkTarget)) }
                fence = opening.marker
                return
            }

            scanLine(line, in: ns, into: &spans)
        }

        return spans
    }

    /// One line, outside any fence. Block markers first — they decide what the rest of the line even
    /// is — then the inline pass over whatever is left.
    private static func scanLine(_ line: NSRange, in ns: NSString, into spans: inout [Span]) {
        // An indented code block: four spaces (or a tab) with content behind them.
        if firstMatch(Patterns.indentedCode, in: ns, range: line) != nil {
            spans.append(Span(range: line, token: .code))
            return
        }

        if let rule = firstMatch(Patterns.thematicBreak, in: ns, range: line) {
            spans.append(Span(range: rule.range, token: .structural))
            return
        }

        var content = line

        if let heading = firstMatch(Patterns.heading, in: ns, range: content) {
            let hashes = heading.range(at: 1)
            spans.append(Span(range: hashes, token: .structural))
            let level = hashes.length
            let rest = NSRange(location: hashes.upperBound, length: content.upperBound - hashes.upperBound)
            if rest.length > 0 {
                spans.append(Span(range: rest, token: .heading(level: level)))
            }
            // **A heading's text takes no inline pass.** It is already emphasized, and layering a
            // body-sized bold run inside a larger heading font would make `# A **bold** title` read
            // as a heading with a hole in it. Dimming the `#` and emphasizing the rest is the whole
            // of what 05 asks for here.
            return
        }

        if let quote = firstMatch(Patterns.blockQuote, in: ns, range: content) {
            spans.append(Span(range: quote.range(at: 1), token: .structural))
            content = NSRange(
                location: quote.range.upperBound,
                length: content.upperBound - quote.range.upperBound
            )
        }

        if let item = firstMatch(Patterns.listItem, in: ns, range: content) {
            spans.append(Span(range: item.range(at: 2), token: .listMarker))
            var after = NSRange(
                location: item.range.upperBound,
                length: content.upperBound - item.range.upperBound
            )
            // A task checkbox is part of the marker, not of the text: `- [x] done`.
            if let box = firstMatch(Patterns.taskBox, in: ns, range: after) {
                spans.append(Span(range: box.range, token: .listMarker))
                after = NSRange(location: box.range.upperBound, length: after.upperBound - box.range.upperBound)
            }
            content = after
        }

        scanInlines(content, in: ns, into: &spans)
    }

    /// The inline pass, in precedence order — a code span wins over everything inside it, a link's
    /// target is never emphasis, and `**` is tried before `*` so bold does not read as two italics.
    ///
    /// Claiming is by intersection against what earlier passes already took, which is what makes the
    /// order meaningful and the output non-overlapping.
    private static func scanInlines(_ range: NSRange, in ns: NSString, into spans: inout [Span]) {
        guard range.length > 0 else { return }
        var claimed: [NSRange] = []

        func claim(_ match: NSTextCheckingResult, emit: (NSTextCheckingResult) -> [Span]) {
            guard !claimed.contains(where: { NSIntersectionRange($0, match.range).length > 0 }) else { return }
            claimed.append(match.range)
            spans.append(contentsOf: emit(match))
        }

        for match in matches(Patterns.codeSpan, in: ns, range: range) {
            claim(match) { match in
                [
                    Span(range: match.range(at: 1), token: .structural),
                    Span(range: match.range(at: 2), token: .code),
                    Span(range: match.range(at: 3), token: .structural)
                ]
            }
        }

        for match in matches(Patterns.link, in: ns, range: range) {
            claim(match) { match in
                var emitted: [Span] = []
                // The `!` of an image, the brackets and the parens: all dimmed structure.
                let openText = NSRange(location: match.range.location, length: match.range(at: 1).location - match.range.location)
                if openText.length > 0 { emitted.append(Span(range: openText, token: .structural)) }
                emitted.append(Span(range: match.range(at: 1), token: .linkText))
                let between = NSRange(
                    location: match.range(at: 1).upperBound,
                    length: match.range(at: 2).location - match.range(at: 1).upperBound
                )
                if between.length > 0 { emitted.append(Span(range: between, token: .structural)) }
                emitted.append(Span(range: match.range(at: 2), token: .linkTarget))
                let close = NSRange(
                    location: match.range(at: 2).upperBound,
                    length: match.range.upperBound - match.range(at: 2).upperBound
                )
                if close.length > 0 { emitted.append(Span(range: close, token: .structural)) }
                return emitted
            }
        }

        for match in matches(Patterns.autolink, in: ns, range: range) {
            claim(match) { match in
                [Span(range: match.range, token: .linkTarget)]
            }
        }

        for (pattern, token) in [
            (Patterns.strong, Token.strong),
            (Patterns.strikethrough, Token.strikethrough),
            (Patterns.emphasis, Token.emphasis)
        ] {
            for match in matches(pattern, in: ns, range: range) {
                claim(match) { match in
                    [
                        Span(range: match.range(at: 1), token: .structural),
                        Span(range: match.range(at: 2), token: token),
                        Span(range: match.range(at: 3), token: .structural)
                    ]
                }
            }
        }

        spans.sort { $0.range.location < $1.range.location }
    }

    // MARK: - Application

    /// Lays the pass over a text storage: base attributes everywhere, then each span's own on top.
    ///
    /// **The only mutation is attributes.** `setAttributes` resets the whole body to the base run so
    /// deleted markup cannot leave its styling behind, and `addAttribute` layers each span — no
    /// character is inserted, removed, or replaced, which is 05's "presentation only" enforced by
    /// what this function is able to call.
    ///
    /// Wrapped in `beginEditing`/`endEditing` so the layout manager relays once for the whole pass
    /// rather than once per span.
    @MainActor
    static func apply(_ spans: [Span], to storage: NSTextStorage, pointSize: CGFloat) {
        let full = NSRange(location: 0, length: storage.length)
        storage.beginEditing()
        storage.setAttributes(baseAttributes(pointSize: pointSize), range: full)
        for span in spans {
            let range = NSIntersectionRange(span.range, full)
            guard range.length > 0 else { continue }
            for (key, value) in attributes(for: span.token, pointSize: pointSize) {
                storage.addAttribute(key, value: value, range: range)
            }
        }
        storage.endEditing()
    }

    /// Highlights `storage`'s current string in place — the editor's per-keystroke call.
    @MainActor
    static func highlight(_ storage: NSTextStorage, pointSize: CGFloat) {
        apply(spans(in: storage.string), to: storage, pointSize: pointSize)
    }

    /// The unstyled run: monospaced, at the body size, in the label colour. Also the editor's
    /// `typingAttributes`, so a character typed at the end of a styled run starts out plain and the
    /// next pass — one keystroke later — decides what it really is.
    @MainActor
    static func baseAttributes(pointSize: CGFloat) -> [NSAttributedString.Key: Any] {
        [
            .font: monospaced(pointSize, weight: .regular),
            .foregroundColor: NSColor.labelColor
        ]
    }

    /// One token's presentation. Deliberately restrained — this is an editor, not a preview: the type
    /// stays monospaced throughout so columns line up, and the differences are weight, slant and
    /// colour.
    @MainActor
    static func attributes(for token: Token, pointSize: CGFloat) -> [NSAttributedString.Key: Any] {
        switch token {
        case let .heading(level):
            // Emphasized, and larger for the top two levels only — enough to read as a heading in a
            // monospaced grid without turning the editor into a preview.
            let scale: CGFloat = level <= 1 ? 1.25 : (level == 2 ? 1.12 : 1.0)
            return [
                .font: monospaced((pointSize * scale).rounded(), weight: .bold),
                .foregroundColor: NSColor.labelColor
            ]
        case .strong:
            return [.font: monospaced(pointSize, weight: .bold)]
        case .emphasis:
            return [.font: italic(monospaced(pointSize, weight: .regular))]
        case .strikethrough:
            return [
                .strikethroughStyle: NSUnderlineStyle.single.rawValue,
                .foregroundColor: NSColor.secondaryLabelColor
            ]
        case .code:
            // Tinted rather than boxed: a background behind every code line in an editor makes the
            // caret hard to find.
            return [.foregroundColor: NSColor.systemTeal]
        case .listMarker:
            return [.foregroundColor: NSColor.controlAccentColor]
        case .linkText:
            return [.foregroundColor: NSColor.linkColor]
        case .linkTarget:
            return [.foregroundColor: NSColor.tertiaryLabelColor]
        case .structural:
            return [.foregroundColor: NSColor.tertiaryLabelColor]
        }
    }

    @MainActor
    private static func monospaced(_ size: CGFloat, weight: NSFont.Weight) -> NSFont {
        NSFont.monospacedSystemFont(ofSize: size, weight: weight)
    }

    @MainActor
    private static func italic(_ font: NSFont) -> NSFont {
        NSFontManager.shared.convert(font, toHaveTrait: .italicFontMask)
    }

    // MARK: - Line walking

    /// Every line's range, newline **excluded** — a line's terminator is not part of anything it
    /// carries, and including it would let a heading's colour bleed onto the next line's start in a
    /// wrapped layout.
    ///
    /// **`location < length`, strictly.** A position *at* the end of a text that does not end in a
    /// newline is still inside the last line, so `lineRange(for:)` answers with that line's range —
    /// which starts before the position asked about. Walking to `<=` therefore re-visits the last
    /// line forever on any text whose final line is unterminated, which in an editor is every text
    /// the user is in the middle of typing. The `upperBound > location` guard below is the same
    /// promise made twice: the walk advances or it stops.
    private static func forEachLine(in ns: NSString, _ visit: (NSRange) -> Void) {
        var location = 0
        while location < ns.length {
            let line = ns.lineRange(for: NSRange(location: location, length: 0))
            var content = line
            // Strip the terminator (`\n`, `\r\n`, `\r`, or a Unicode line separator).
            while content.length > 0 {
                let last = ns.character(at: content.upperBound - 1)
                guard last == 0x0A || last == 0x0D || last == 0x2028 || last == 0x2029 else { break }
                content.length -= 1
            }
            if content.length > 0 { visit(content) }
            guard line.upperBound > location else { return }
            location = line.upperBound
        }
    }

    /// A line's opening or closing code fence, if it has one: the run of backticks or tildes, and
    /// which of the two it is (a ``` block is not closed by a ~~~ line).
    private static func fenceRun(in ns: NSString, line: NSRange) -> (range: NSRange, marker: String)? {
        guard let match = firstMatch(Patterns.fence, in: ns, range: line) else { return nil }
        let run = match.range(at: 1)
        return (range: match.range, marker: ns.substring(with: NSRange(location: run.location, length: 1)))
    }

    // MARK: - Regex plumbing

    private static func matches(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> [NSTextCheckingResult] {
        pattern.matches(in: ns as String, options: [], range: range)
    }

    private static func firstMatch(_ pattern: NSRegularExpression, in ns: NSString, range: NSRange) -> NSTextCheckingResult? {
        pattern.firstMatch(in: ns as String, options: [], range: range)
    }

    /// Compiled once. Each is anchored the way its construct is anchored in Markdown — block
    /// patterns at the start of a line, inline patterns anywhere in it.
    ///
    /// `try!` is load-bearing rather than lazy: these are literals, so a failure here is a typo that
    /// would fail on the first launch of a debug build, not a runtime condition a user can reach.
    private enum Patterns {
        static let heading = regex("^ {0,3}(#{1,6})(?:[ \t]|$)")
        static let thematicBreak = regex("^ {0,3}(?:(?:\\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$")
        static let blockQuote = regex("^[ \t]*(>+)[ \t]?")
        static let listItem = regex("^([ \t]*)([-*+]|\\d{1,9}[.)])(?=[ \t])")
        static let taskBox = regex("^[ \t]*\\[[ xX]\\]")
        static let fence = regex("^ {0,3}(`{3,}|~{3,})")
        static let indentedCode = regex("^(?: {4}|\t)[ \t]*\\S")
        static let codeSpan = regex("(`+)([^`]*)(\\1)")
        static let link = regex("!?\\[([^\\]\\n]*)\\]\\(([^)\\n]*)\\)")
        static let autolink = regex("<(?:https?|mailto|file):[^>\\s]*>")
        static let strong = regex("(\\*\\*|__)((?:(?!\\1).)+)(\\1)")
        static let emphasis = regex("(?<![*_\\w])([*_])((?:(?!\\1)[^\\s])(?:(?!\\1).)*)(\\1)(?![*_\\w])")
        static let strikethrough = regex("(~~)((?:(?!~~).)+)(~~)")

        private static func regex(_ pattern: String) -> NSRegularExpression {
            // swiftlint:disable:next force_try
            try! NSRegularExpression(pattern: pattern, options: [])
        }
    }
}
