import AppKit
import Foundation
import Testing
@testable import Kanban

/// The Edit editor's syntax highlighting (05-card-window.md ▸ Edit).
///
/// The suite exists for one promise above all others — **"highlighting is presentation only: the text
/// is the raw Markdown, character for character"** — and the whole reason the highlighter emits
/// `[Span]` rather than an attributed string is so that promise is checkable rather than merely
/// intended. The invariants below (in bounds, in order, non-overlapping, string unchanged after a
/// full application) hold for *every* input, so they are asserted over a corpus of deliberately
/// broken Markdown as well as over the tidy examples.

// MARK: - Helpers

private func spans(_ text: String) -> [MarkdownHighlighter.Span] {
    MarkdownHighlighter.spans(in: text)
}

/// The substrings a token claims, in order — assertions read as "what is bold here?" rather than as
/// arithmetic over offsets.
private func text(of text: String, token: MarkdownHighlighter.Token) -> [String] {
    let ns = text as NSString
    return spans(text).filter { $0.token == token }.map { ns.substring(with: $0.range) }
}

/// Every span's substring, whatever its token.
private func claimed(_ text: String, where predicate: (MarkdownHighlighter.Token) -> Bool) -> [String] {
    let ns = text as NSString
    return spans(text).filter { predicate($0.token) }.map { ns.substring(with: $0.range) }
}

// MARK: - Invariants

@Suite("Markdown highlighter ▸ invariants")
struct MarkdownHighlighterInvariantTests {

    /// Tidy Markdown, half-typed Markdown, and text that is not Markdown at all — the editor holds
    /// all three, usually within a second of each other.
    static let corpus: [String] = [
        "",
        "\n",
        "plain prose with no markup at all",
        "# Heading\n\nBody *text* here.\n",
        "**bo",
        "[label](",
        "`unclosed code",
        "~~~\nfence with no close\n",
        "***",
        "- [ ] task\n- [x] done\n  - nested\n",
        "> quoted **bold**\n>> deeper\n",
        "| a | b |\n| - | - |\n| 1 | 2 |\n",
        "snake_case_identifier and 2 * 3 * 4\n",
        "```swift\nlet x = **not bold**\n```\n",
        "    indented code\n",
        "emoji 🇬🇧 and combining é in *italics*\n",
        "<https://example.com> and ![alt](shot.png)\n"
    ]

    @Test("Every span lands inside the text, in order, without overlapping")
    func spansPartitionCleanly() {
        for sample in Self.corpus {
            let length = (sample as NSString).length
            var previousEnd = 0
            for span in spans(sample) {
                #expect(span.range.location >= 0)
                #expect(span.range.upperBound <= length, "a span past the end of \(sample.debugDescription)")
                #expect(span.range.location >= previousEnd, "spans overlap or go backwards in \(sample.debugDescription)")
                previousEnd = span.range.upperBound
            }
        }
    }

    @MainActor
    @Test("Applying the whole pass never changes a single character")
    func applyingNeverAltersTheString() {
        for sample in Self.corpus {
            let storage = NSTextStorage(string: sample)
            MarkdownHighlighter.highlight(storage, pointSize: 13)
            #expect(storage.string == sample, "the highlighter rewrote \(sample.debugDescription)")
            // And again, because an idempotent pass is what running on every keystroke amounts to.
            MarkdownHighlighter.highlight(storage, pointSize: 13)
            #expect(storage.string == sample)
        }
    }

    @MainActor
    @Test("A pass leaves no attributes from the pass before it")
    func attributesAreRebuiltRatherThanAccumulated() {
        let storage = NSTextStorage(string: "# Heading\n")
        MarkdownHighlighter.highlight(storage, pointSize: 13)
        // The user deletes the `#`: what was a heading is now prose, and must be drawn as prose.
        storage.replaceCharacters(in: NSRange(location: 0, length: 2), with: "")
        MarkdownHighlighter.highlight(storage, pointSize: 13)

        let base = MarkdownHighlighter.baseAttributes(pointSize: 13)
        let font = storage.attribute(.font, at: 0, effectiveRange: nil) as? NSFont
        #expect(font == base[.font] as? NSFont, "a stale heading font would survive the edit that ended the heading")
    }
}

// MARK: - Blocks

@Suite("Markdown highlighter ▸ blocks")
struct MarkdownHighlighterBlockTests {

    @Test("A heading is its marker, dimmed, and its text, emphasized")
    func headings() {
        #expect(text(of: "# Title\n", token: .heading(level: 1)) == [" Title"])
        #expect(text(of: "### Deeper\n", token: .heading(level: 3)) == [" Deeper"])
        #expect(text(of: "# Title\n", token: .structural) == ["#"])
        // Seven hashes is not a heading in CommonMark, and is not one here either.
        #expect(text(of: "####### nope\n", token: .heading(level: 7)).isEmpty)
        #expect(text(of: "#nospace\n", token: .heading(level: 1)).isEmpty)
    }

    @Test("List markers and task boxes are the marker, not the text")
    func listMarkers() {
        #expect(text(of: "- item\n", token: .listMarker) == ["-"])
        #expect(text(of: "1. item\n", token: .listMarker) == ["1."])
        #expect(text(of: "  * nested\n", token: .listMarker) == ["*"])
        // The checkbox belongs to the marker: `- [x] done` reads as one control plus a label.
        #expect(text(of: "- [x] done\n", token: .listMarker) == ["-", " [x]"])
        #expect(text(of: "- [ ] todo\n", token: .listMarker) == ["-", " [ ]"])
    }

    @Test("A thematic break is structure, and is not three list markers")
    func thematicBreaks() {
        #expect(text(of: "---\n", token: .structural) == ["---"])
        #expect(text(of: "***\n", token: .structural) == ["***"])
        #expect(text(of: "---\n", token: .listMarker).isEmpty)
    }

    @Test("A quote's chevrons dim and its content still highlights")
    func quotes() {
        #expect(text(of: "> quoted **bold**\n", token: .structural).contains(">"))
        #expect(text(of: "> quoted **bold**\n", token: .strong) == ["bold"])
    }

    @Test("A fenced block is code from fence to fence, whatever is inside it")
    func fencedCode() {
        let sample = "```swift\nlet x = **not bold**\n# not a heading\n```\nafter\n"

        #expect(text(of: sample, token: .strong).isEmpty, "markup inside a fence is code, not markup")
        #expect(text(of: sample, token: .heading(level: 1)).isEmpty)
        #expect(text(of: sample, token: .code).contains("let x = **not bold**"))
        #expect(text(of: sample, token: .code).contains("# not a heading"))
        // The info string is dimmed with the fence rather than tinted as code.
        #expect(text(of: sample, token: .linkTarget) == ["swift"])
        // And the block ends: text after the closing fence is ordinary again.
        #expect(!text(of: sample, token: .code).contains("after"))
    }

    @Test("A tilde fence is not closed by a backtick fence")
    func fenceMarkersMustMatch() {
        let sample = "~~~\ncode\n```\nstill code\n~~~\nout\n"
        #expect(text(of: sample, token: .code).contains("still code"))
        #expect(!text(of: sample, token: .code).contains("out"))
    }

    @Test("An unclosed fence simply runs to the end — an editor is full of half-typed blocks")
    func anUnclosedFenceDoesNotBreakTheRest() {
        let sample = "```\ncode\nmore code\n"
        #expect(text(of: sample, token: .code) == ["code", "more code"])
    }

    @Test("Indented code is code")
    func indentedCode() {
        #expect(text(of: "    let x = 1\n", token: .code) == ["    let x = 1"])
        #expect(text(of: "\tlet x = 1\n", token: .code) == ["\tlet x = 1"])
    }
}

// MARK: - Inlines

@Suite("Markdown highlighter ▸ inlines")
struct MarkdownHighlighterInlineTests {

    @Test("Bold, italic and strikethrough style their content and dim their delimiters")
    func emphasis() {
        #expect(text(of: "a **bold** b\n", token: .strong) == ["bold"])
        #expect(text(of: "a **bold** b\n", token: .structural) == ["**", "**"])
        #expect(text(of: "a *italic* b\n", token: .emphasis) == ["italic"])
        #expect(text(of: "a _italic_ b\n", token: .emphasis) == ["italic"])
        #expect(text(of: "a ~~struck~~ b\n", token: .strikethrough) == ["struck"])
        // `**` is tried before `*`, so bold is bold rather than two adjacent italics.
        #expect(text(of: "**bold**\n", token: .emphasis).isEmpty)
    }

    @Test("Intraword underscores are not emphasis")
    func underscoresInWords() {
        #expect(text(of: "snake_case_name here\n", token: .emphasis).isEmpty)
        #expect(text(of: "2 * 3 * 4\n", token: .emphasis).isEmpty, "spaced asterisks are arithmetic")
    }

    @Test("A code span tints its content and outranks the markup inside it")
    func codeSpans() {
        #expect(text(of: "use `let x = **y**` here\n", token: .code) == ["let x = **y**"])
        #expect(text(of: "use `let x = **y**` here\n", token: .strong).isEmpty)
        #expect(claimed("`a`\n") { $0 == .structural } == ["`", "`"])
    }

    @Test("A link's text reads as text and its target dims")
    func links() {
        let sample = "see [the docs](https://example.com/x) now\n"
        #expect(text(of: sample, token: .linkText) == ["the docs"])
        #expect(text(of: sample, token: .linkTarget) == ["https://example.com/x"])
        // The brackets, the parens and an image's `!` are all structure.
        #expect(text(of: sample, token: .structural) == ["[", "](", ")"])
        #expect(text(of: "![alt](shot.png)\n", token: .structural) == ["![", "](", ")"])
        #expect(text(of: "<https://example.com>\n", token: .linkTarget) == ["<https://example.com>"])
    }

    @Test("Emphasis inside a link's text does not eat the link")
    func overlappingConstructsResolveByPrecedence() {
        let sample = "[a **b** c](url)\n"
        #expect(text(of: sample, token: .linkText) == ["a **b** c"])
        #expect(text(of: sample, token: .linkTarget) == ["url"])
        #expect(text(of: sample, token: .strong).isEmpty, "the link claimed the run first")
    }

    @Test("Half-typed markup styles what is there and invents nothing")
    func halfTypedMarkup() {
        // The delimiters dim as they are typed; the run styles when it closes. Nothing about this
        // is an error state, which is the whole reason the editor scans lines rather than parsing.
        #expect(text(of: "**bo\n", token: .strong).isEmpty)
        #expect(text(of: "[label](\n", token: .linkText).isEmpty)
        #expect(text(of: "`unclosed\n", token: .code).isEmpty)
    }

    @Test("Offsets survive text no ASCII assumption would")
    func unicodeOffsets() {
        // NSRange is UTF-16, and an emoji flag is two code units before the markup even starts —
        // a highlighter counting characters would style the wrong run here.
        let sample = "🇬🇧 flag then **bold**\n"
        #expect(text(of: sample, token: .strong) == ["bold"])
        #expect(text(of: "é *accented* text\n", token: .emphasis) == ["accented"])
    }
}
