import Foundation

/// **One lenient Markdown parser for both Markdown flavours** (15-import-export.md ▸ Import is one row).
///
/// The Obsidian Kanban plugin's document *is* a plain outline wearing a frontmatter marker, so there is
/// no second parser for it — the marker decides what the importer reports it found, not how the file is
/// read.
///
/// ### The rules, and the one promise above them
///
/// **It never fails.** There is no thrown error and no rejected shape: every line of the input ends up
/// somewhere, and the worst a genuinely odd file gets is every card in one lane called "Imported". That
/// is the whole posture — a converter that refuses a file the user is trying to leave behind has failed
/// at the only job it has.
///
/// - **Lane level is whichever heading level the document actually uses.** `##` are lanes when the file
///   has any; otherwise `#` are. A single `#` *above* the first lane is the board's title (the plain
///   outline export's own shape); an `#` after lanes have started is another lane, because by then it is
///   plainly being used as a section break.
/// - **Deeper headings are cards.** `###` under `##` is the outline reading of a sub-item, and it is how
///   a hand-written doc ("### Task A", then notes) imports the way its author meant.
/// - **Bullets are cards**: `-`, `*`, `+`, and ordered `1.` / `1)`, with or without a `[ ]` / `[x]` task
///   marker. **The marker is read and discarded** — Lanework has no done flag, so a checked item imports
///   as an ordinary card rather than as something the app would have to invent a meaning for
///   (`MarkdownBoardWriter`'s own note about the export side of the same rule).
/// - **Indented lines are the current card's body**, dedented by the first continuation line's own
///   indent so nested lists and code keep their relative shape.
/// - **Column-0 prose belongs to the card above it** — to a *bullet's* card only as CommonMark's lazy
///   continuation (no blank line between), and to a *heading's* card until the next heading or bullet,
///   because that is what a section is. Prose that belongs to neither becomes a card of its own.
///   Nothing is dropped in any of the three cases, which is the promise
///   (`OutlineAccumulator.absorbsProse` states the rule once).
/// - **Obsidian `%%` comments and thematic breaks are skipped.** The plugin's trailing
///   `%% kanban:settings %%` block is the reason: read as prose it would become a card holding the
///   plugin's JSON. The plugin's `***` archive separator goes the same way — and the `## Archive`
///   heading below it imports as an ordinary lane named Archive, which is the honest landing place for
///   cards this app has no archive to put in.
/// - **Column-0 fenced code is held together**: a ``` or `~~~ fence toggles a verbatim stretch that
///   rides into the current card's body unchanged, so a code block pasted at the left margin is not
///   shredded into one card per line.
public enum MarkdownBoardParser {

    /// The lane an import falls back to when content arrives before any heading does — and the lane a
    /// CSV with no lane column lands in (`CSVBoardParser`), deliberately the same word in both places.
    public static let defaultLaneTitle = "Imported"

    // MARK: - Parse

    public static func parse(_ text: String) -> InterchangeBoard {
        let all = lines(of: text)
        let body = skippingFrontmatter(all)
        let laneLevel = laneHeadingLevel(in: body)

        var accumulator = OutlineAccumulator()
        var commentDepth = 0
        var inFence = false

        for line in body {
            // Obsidian comments first: their content is arbitrary and must never be read as structure.
            if commentDepth > 0 {
                if isCommentDelimiter(line) { commentDepth = 0 }
                continue
            }
            if !inFence, let single = commentOpener(line) {
                if !single { commentDepth = 1 }
                continue
            }

            if isFenceDelimiter(line) {
                inFence.toggle()
                accumulator.appendVerbatim(line)
                continue
            }
            if inFence {
                if line.trimmingCharacters(in: .whitespaces).isEmpty {
                    accumulator.appendBlank()
                } else {
                    accumulator.appendVerbatim(line)
                }
                continue
            }

            if line.trimmingCharacters(in: .whitespaces).isEmpty {
                accumulator.appendBlank()
                continue
            }
            if isThematicBreak(line) {
                accumulator.closeCard()
                continue
            }

            if let heading = heading(of: line) {
                if heading.level == laneLevel {
                    accumulator.startLane(title: heading.text.isEmpty ? nil : heading.text)
                } else if heading.level > laneLevel {
                    accumulator.startCard(title: heading.text.isEmpty ? nil : heading.text, isSection: true)
                } else if accumulator.isEmpty, accumulator.boardTitle == nil {
                    accumulator.boardTitle = heading.text.isEmpty ? nil : heading.text
                } else {
                    accumulator.startLane(title: heading.text.isEmpty ? nil : heading.text)
                }
                continue
            }

            if let label = listItemLabel(of: line) {
                accumulator.startCard(title: label.isEmpty ? nil : label, isSection: false)
                continue
            }

            if isIndented(line) {
                accumulator.appendContinuation(line)
                continue
            }

            // Column-0 prose: the current card's body when it can still absorb one, a card of its own
            // otherwise (`OutlineAccumulator.absorbsProse`).
            if accumulator.absorbsProse {
                accumulator.appendContinuation(line)
            } else {
                accumulator.startCard(title: line.trimmingCharacters(in: .whitespaces), isSection: false)
            }
        }

        return accumulator.finish()
    }

    // MARK: - Text plumbing

    /// Line endings folded to `\n` and a UTF-8 BOM dropped — the one normalization every reader here
    /// starts from, so no rule below has to spell a `\r` case (01-storage-format.md § Encoding and line
    /// endings, read for input the app did not write).
    public static func normalized(_ text: String) -> String {
        var value = text
        if value.hasPrefix("\u{FEFF}") { value.removeFirst() }
        return value
            .replacingOccurrences(of: "\r\n", with: "\n")
            .replacingOccurrences(of: "\r", with: "\n")
    }

    static func lines(of text: String) -> [String] {
        normalized(text).components(separatedBy: "\n")
    }

    /// The inner lines of a leading `---` … `---` frontmatter block, or `nil` when there is none.
    ///
    /// Handed out rather than kept private because the format detector asks the same question of the
    /// same block (`InterchangeFormat.frontmatterCarriesKanbanPlugin`) and the two must agree about what
    /// counts as one.
    static func frontmatterBlock(in lines: [String]) -> [String]? {
        guard let first = lines.first, isFrontmatterDelimiter(first) else { return nil }
        guard let end = lines.dropFirst().firstIndex(where: isFrontmatterDelimiter) else { return nil }
        return Array(lines[1..<end])
    }

    /// Everything after the frontmatter block, or the whole document when there is none.
    static func skippingFrontmatter(_ lines: [String]) -> ArraySlice<String> {
        guard let first = lines.first, isFrontmatterDelimiter(first) else { return lines[...] }
        guard let end = lines.dropFirst().firstIndex(where: isFrontmatterDelimiter) else { return lines[...] }
        return lines[(end + 1)...]
    }

    private static func isFrontmatterDelimiter(_ line: String) -> Bool {
        let trimmed = line.trimmingCharacters(in: .whitespaces)
        return trimmed == "---" || trimmed == "..."
    }

    // MARK: - Line shapes

    /// `##` when the document has any H2, `#` when it only has H1s, `##` when it has no headings at all
    /// (nothing will match, and every card falls into the default lane — which is the promise).
    static func laneHeadingLevel(in lines: some Sequence<String>) -> Int {
        var sawH1 = false
        for line in lines {
            guard let heading = heading(of: line) else { continue }
            if heading.level >= 2 { return 2 }
            if heading.level == 1 { sawH1 = true }
        }
        return sawH1 ? 1 : 2
    }

    /// An ATX heading at column 0 — `#` through `######`, with a space after the run or nothing at all.
    /// A run with text jammed against it (`#hashtag`) is not a heading, which is CommonMark's rule and
    /// also what keeps a tag line from becoming a lane.
    static func heading(of line: String) -> (level: Int, text: String)? {
        guard line.hasPrefix("#") else { return nil }
        let hashes = line.prefix { $0 == "#" }
        guard hashes.count <= 6 else { return nil }
        let rest = line.dropFirst(hashes.count)
        guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
        var text = rest.trimmingCharacters(in: .whitespaces)
        // A closed ATX heading (`## Done ##`) drops its trailing run — CommonMark's own reading,
        // including its condition that the run be preceded by whitespace, so a lane genuinely titled
        // "C#" keeps its name.
        if text.hasSuffix("#") {
            let run = text.reversed().prefix { $0 == "#" }.count
            let before = text.dropLast(run)
            if before.isEmpty || before.last == " " || before.last == "\t" {
                text = String(before)
            }
        }
        return (hashes.count, text.trimmingCharacters(in: .whitespaces))
    }

    /// A list item at column 0, answered as its label with any task marker already stripped. `nil` for
    /// anything that is not one.
    static func listItemLabel(of line: String) -> String? {
        guard let rest = listItemRemainder(of: line) else { return nil }
        return strippingTaskMarker(rest).trimmingCharacters(in: .whitespaces)
    }

    private static func listItemRemainder(of line: String) -> String? {
        guard let first = line.first else { return nil }
        if "-*+".contains(first) {
            let rest = line.dropFirst()
            guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
            return String(rest)
        }
        guard first.isNumber else { return nil }
        let digits = line.prefix { $0.isNumber }
        // At most nine digits is CommonMark's own cap, and it keeps a bare year from opening a list.
        guard digits.count <= 9 else { return nil }
        let afterDigits = line.dropFirst(digits.count)
        guard let delimiter = afterDigits.first, delimiter == "." || delimiter == ")" else { return nil }
        let rest = afterDigits.dropFirst()
        guard rest.isEmpty || rest.first == " " || rest.first == "\t" else { return nil }
        return String(rest)
    }

    /// `[ ]`, `[x]`, `[X]` and the plugin's own `[X]` variants, removed from the front of a label.
    /// **Read and discarded** — see this type's own note about why there is nothing to import it into.
    private static func strippingTaskMarker(_ text: String) -> String {
        let trimmed = text.trimmingCharacters(in: .whitespaces)
        guard trimmed.hasPrefix("[") else { return text }
        let scalars = Array(trimmed)
        guard scalars.count >= 3, scalars[2] == "]" else { return text }
        let state = scalars[1]
        guard state == " " || state == "x" || state == "X" else { return text }
        return String(trimmed.dropFirst(3))
    }

    static func isIndented(_ line: String) -> Bool {
        guard let first = line.first else { return false }
        return first == " " || first == "\t"
    }

    /// `---`, `***`, `___` — three or more of one character, spaces allowed between them.
    static func isThematicBreak(_ line: String) -> Bool {
        let trimmed = line.trimmingCharacters(in: .whitespaces)
        guard let first = trimmed.first, "-*_".contains(first) else { return false }
        let stripped = trimmed.filter { !$0.isWhitespace }
        return stripped.count >= 3 && stripped.allSatisfy { $0 == first }
    }

    /// A fence line at column 0 — three or more backticks or tildes. The info string is ignored: this
    /// only needs to know that a verbatim stretch opened or closed.
    static func isFenceDelimiter(_ line: String) -> Bool {
        guard let first = line.first, first == "`" || first == "~" else { return false }
        return line.prefix { $0 == first }.count >= 3
    }

    /// Whether the line opens an Obsidian `%%` comment, and whether it also closes it on the same line.
    private static func commentOpener(_ line: String) -> Bool? {
        let trimmed = line.trimmingCharacters(in: .whitespaces)
        guard trimmed.hasPrefix("%%") else { return nil }
        return trimmed.count > 2 && trimmed.hasSuffix("%%")
    }

    private static func isCommentDelimiter(_ line: String) -> Bool {
        line.trimmingCharacters(in: .whitespaces).hasSuffix("%%")
    }

    /// **Whether a line carries Markdown structure** — an ATX heading or a list item at column 0. The
    /// format detector's veto (`InterchangeFormat.looksLikeCSV`) asks exactly this and nothing else: one
    /// such line anywhere in a file is enough to mean the file is an outline, whatever its commas say.
    static func isMarkdownStructure(_ line: String) -> Bool {
        heading(of: line) != nil || listItemRemainder(of: line) != nil
    }
}

// MARK: - The walk's state

/// The parser's running state, extracted so the walk above reads as its own rules rather than as
/// bookkeeping — and so "close the open card, then the open lane, then answer" happens in exactly one
/// place instead of at every branch that ends one.
private struct OutlineAccumulator {

    var boardTitle: String?

    private var lanes: [InterchangeLane] = []
    private var lane: InterchangeLane?
    private var cardTitle: String??
    /// Whether the open card came from a **heading** rather than a bullet — see `absorbsProse`.
    private var cardIsSection = false
    private var bodyLines: [String] = []
    private var indentPrefix: String?
    private var pendingBlanks = 0

    /// Nothing has been read yet — the window in which a shallow heading is the board's title rather
    /// than a lane.
    var isEmpty: Bool { lanes.isEmpty && lane == nil && cardTitle == nil }

    /// **Whether column-0 prose belongs to the open card**, which the two kinds of card answer
    /// differently — and deliberately so, because Markdown itself does:
    ///
    /// - A card from a **bullet** takes prose only as CommonMark's *lazy continuation*: no blank line
    ///   between them. A blank line ends the list item, and what follows is its own thing.
    /// - A card from a **heading** takes everything until the next heading or bullet, blank lines
    ///   included, because that is what a heading's section *is*. A `### Task A` followed by a blank
    ///   line and two paragraphs of notes is one card with a body, and reading the notes as a second
    ///   card would be the parser ignoring the only structure the document has.
    var absorbsProse: Bool { cardTitle != nil && (cardIsSection || pendingBlanks == 0) }

    mutating func startLane(title: String?) {
        closeLane()
        lane = InterchangeLane(title: title)
    }

    mutating func startCard(title: String?, isSection: Bool) {
        closeCard()
        cardTitle = .some(title)
        cardIsSection = isSection
    }

    mutating func appendBlank() {
        // A blank line before any card is structure, not content: it separates a heading from its
        // items and must not become a leading empty line in the next card's body.
        guard cardTitle != nil else { return }
        pendingBlanks += 1
    }

    /// An indented (or lazily-continued) body line, dedented by the first continuation's own indent.
    ///
    /// **The first continuation sets the prefix for the whole item**, and a later line that does not
    /// carry it is stripped of whatever leading whitespace it has. That keeps a body's *relative*
    /// indentation — a nested list, an indented code block — while never leaving a stray two spaces on
    /// content the exporter will indent again on the way back out.
    mutating func appendContinuation(_ line: String) {
        guard cardTitle != nil else {
            startCard(title: line.trimmingCharacters(in: .whitespaces), isSection: false)
            return
        }
        if indentPrefix == nil {
            indentPrefix = String(line.prefix { $0 == " " || $0 == "\t" })
        }
        let dedented: String
        if let prefix = indentPrefix, !prefix.isEmpty, line.hasPrefix(prefix) {
            dedented = String(line.dropFirst(prefix.count))
        } else {
            dedented = String(line.drop { $0 == " " || $0 == "\t" })
        }
        flushBlanks()
        bodyLines.append(dedented)
    }

    /// A fenced-code line, kept exactly as it arrived: inside a fence, leading whitespace is content.
    mutating func appendVerbatim(_ line: String) {
        guard cardTitle != nil else {
            cardTitle = .some(nil)
            flushBlanks()
            bodyLines.append(line)
            return
        }
        flushBlanks()
        bodyLines.append(line)
    }

    mutating func closeCard() {
        guard let title = cardTitle else { return }
        // Trailing blanks are dropped rather than flushed: they are the separator before whatever comes
        // next, not the tail of this body.
        let card = InterchangeCard(title: title, body: bodyLines.joined(separator: "\n"))
        if lane == nil { lane = InterchangeLane(title: MarkdownBoardParser.defaultLaneTitle) }
        lane?.cards.append(card)
        cardTitle = nil
        cardIsSection = false
        bodyLines = []
        indentPrefix = nil
        pendingBlanks = 0
    }

    mutating func closeLane() {
        closeCard()
        guard let lane else { return }
        lanes.append(lane)
        self.lane = nil
    }

    mutating func finish() -> InterchangeBoard {
        closeLane()
        return InterchangeBoard(title: boardTitle, lanes: lanes)
    }

    /// **Blanks are content only between content.** A blank line before the body's first line is the
    /// separator between a card's marker (or heading) and what follows, so it is dropped — the mirror of
    /// `closeCard`'s trailing drop, and together they are why a body never starts or ends with an empty
    /// line whatever the source file's spacing was.
    private mutating func flushBlanks() {
        if !bodyLines.isEmpty {
            for _ in 0..<pendingBlanks { bodyLines.append("") }
        }
        pendingBlanks = 0
    }
}
