import Foundation

/// **The board as one Markdown file** — the Obsidian Kanban plugin's flavour and the plain outline,
/// which are one serializer because they are one document (15-import-export.md ▸ The formats).
///
/// ### The shape
///
/// ```markdown
/// ---
///
/// kanban-plugin: board
///
/// ---
///
/// ## To Do
///
/// - [ ] Fix login
///   The button does nothing on the second click.
/// - [ ] Ship the beta
///
/// ## Done
///
/// - [ ] Write the changelog
/// ```
///
/// Lanes are `##` headings **in lane order**, cards are list items **in card order**, and that is the
/// entire ordering contract: document position *is* the rank, so nothing is written down and nothing has
/// to be read back. A multi-line body rides as continuation lines indented two spaces under its item,
/// which is ordinary Markdown list continuation and what the plugin itself writes.
///
/// ### The two flavours, and their one difference each
///
/// - **`.obsidianKanban`** opens with the plugin's frontmatter marker, spelled the way the plugin spells
///   it (blank lines inside the delimiters), and writes checkbox items `- [ ]`. It writes **no `#`
///   board title**: the plugin has no such concept — a board's name is its file's name — so the save
///   panel's chosen filename is the title, and an H1 would show up in Obsidian as a stray card-less
///   heading.
/// - **`.markdownOutline`** writes the board's title as an `#` H1 and plain `- ` items. No frontmatter
///   at all, because the point of this flavour is a document that reads as itself wherever it is pasted.
///
/// ### Everything is unchecked, on purpose
///
/// Lanework has no done flag — a card is where it is, and that is the whole model — so **every exported
/// item is `- [ ]`**, including cards in a lane called Done. Inferring checked state from a lane title
/// would be the exporter inventing data out of a string match, and the inverse (importing `- [x]` as
/// something) has nowhere to land. The import side ignores the marker for the same reason. Stated as a
/// limitation in 15 rather than hidden here.
///
/// ### What it does not write
///
/// The plugin's trailing `%% kanban:settings %%` block (lane widths, per-lane "complete" flags, its own
/// display preferences) and its `## Archive` section. Both are the plugin's state about a board rather
/// than the board, this app has no equivalent of either, and a settings block synthesized from defaults
/// would be this app asserting preferences on the user's behalf in another app's file. A board exported
/// from here opens in the plugin with the plugin's own defaults, which is the honest outcome.
public enum MarkdownBoardWriter {

    /// The whole document, LF-terminated (01-storage-format.md § Encoding and line endings — the app's
    /// own rule, and Obsidian's own convention besides).
    public static func text(for board: InterchangeBoard, flavor: InterchangeFormat) -> String {
        var lines: [String] = []

        switch flavor {
        case .obsidianKanban:
            // The plugin's own spelling, blank lines and all — a file byte-shaped like one the plugin
            // wrote is a file it will never have an opinion about.
            lines += ["---", "", "kanban-plugin: board", "", "---", ""]
        case .markdownOutline:
            if let title = singleLine(board.title), !title.isEmpty {
                lines += ["# \(title)", ""]
            }
        case .csv:
            // Not this writer's format. Answering with the outline rather than trapping keeps the
            // function total for a caller that routed wrong; the export command never does.
            return text(for: board, flavor: .markdownOutline)
        }

        for (index, lane) in board.lanes.enumerated() {
            // A blank line *before* each lane after the first, rather than after each lane's items:
            // an empty lane then costs one blank line like every other, instead of two.
            if index > 0 { lines.append("") }
            lines.append("## \(singleLine(lane.title) ?? "")")
            guard !lane.cards.isEmpty else { continue }
            lines.append("")
            for card in lane.cards {
                lines += itemLines(for: card, flavor: flavor)
            }
        }

        return lines.joined(separator: "\n") + "\n"
    }

    /// One card: its marker line, then its body indented under it.
    ///
    /// **An untitled card writes a bare marker** (`- [ ]`, no trailing space) rather than the word
    /// "Untitled": the placeholder is a rendering and never a value (03-board-ui.md § Card face), and
    /// writing it would export a card nobody named as one somebody did. The parse of a bare marker is a
    /// card with a `nil` title, so the pair round-trips.
    ///
    /// **A blank line inside a body stays blank** — no two-space indent on an empty line, since trailing
    /// whitespace is litter, and the parser reads a blank line followed by more indented content as part
    /// of the item it is inside. The cost is that a multi-paragraph card renders as a *loose* list item
    /// in a Markdown viewer; the alternative — collapsing the blank line — would silently reflow the
    /// user's prose.
    static func itemLines(for card: InterchangeCard, flavor: InterchangeFormat) -> [String] {
        let marker = flavor == .obsidianKanban ? "- [ ]" : "-"
        let label = singleLine(card.title) ?? ""
        var lines = [label.isEmpty ? marker : "\(marker) \(label)"]
        for line in bodyLines(of: card.body) {
            lines.append(line.isEmpty ? "" : "\(continuationIndent)\(line)")
        }
        return lines
    }

    /// Two spaces — the plugin's own continuation indent, and the narrowest one Markdown accepts under a
    /// `- ` item, which keeps a body's own nested lists readable in the exported file.
    static let continuationIndent = "  "

    /// A body split into lines, with line endings normalized and a trailing newline dropped.
    ///
    /// The trailing drop matters for the round trip: a body stored as `"text\n"` and one stored as
    /// `"text"` are the same document, and emitting the first as an item followed by a blank line would
    /// make the two export differently.
    private static func bodyLines(of body: String) -> [String] {
        var normalized = MarkdownBoardParser.normalized(body)
        while normalized.hasSuffix("\n") { normalized.removeLast() }
        guard !normalized.isEmpty else { return [] }
        return normalized.components(separatedBy: "\n")
    }

    /// A title flattened to one line, because a list item's label and a heading are both one line.
    ///
    /// Only reachable for a title hand-written as a YAML block scalar — nothing in the app can produce
    /// one — so this is a totality guard rather than a routine transform, and it is spelled as a
    /// substitution rather than a truncation so no character is lost.
    private static func singleLine(_ text: String?) -> String? {
        guard let text else { return nil }
        return MarkdownBoardParser.normalized(text)
            .replacingOccurrences(of: "\n", with: " ")
            .trimmingCharacters(in: .whitespaces)
    }
}
