import Foundation
import Yams

/// One `index.md`: YAML frontmatter between `---` delimiters, then the Markdown body.
///
/// The document keeps the file's raw text and edits it by line span, so anything the caller
/// does not touch is byte-identical on the way out — including unknown keys, their order,
/// comments, blank lines, and the body. YAML is used to *validate and read*, never to write.
public struct FrontmatterDocument: Sendable, Equatable {
    /// A contiguous run of frontmatter lines. `key == nil` is a preamble/orphan run of
    /// comments or blank lines. `tail` is the trailing comment/blank lines the run picked up —
    /// held separately so `set`/`remove` never disturb them.
    private struct Span: Sendable, Equatable {
        var key: String?
        var value: String
        var tail: String

        var text: String { value + tail }
    }

    private struct KeyedValue: Sendable, Equatable {
        var key: String
        var value: YAMLValue
    }

    private var openingDelimiter: String
    private var closingDelimiter: String
    private var spans: [Span]
    private var values: [KeyedValue]

    /// Everything after the closing delimiter line, verbatim.
    public var body: String

    /// A frontmatter shape the span editor cannot address — the reason a document reads fine
    /// but refuses to be written (see `uneditableShape`).
    public enum UneditableShape: Sendable, Equatable, CustomStringConvertible {
        /// A top-level mapping key is not a scalar the editor can address — YAML's explicit-key
        /// syntax (`? [a, b]`), whose key is a sequence or mapping rather than a name.
        case nonScalarKey
        /// A top-level key has no line of its own that opens it: the whole-frontmatter flow
        /// mapping (`{schema: 1, order: 1024}`) and its kin (`key : value`, whose spacing the
        /// span matcher cannot key on). The value reads fine; there is no line to rewrite.
        /// Also covers the empty-but-not-blank block (`{}`, `null`, `~`) — zero keys, yet
        /// appending one after that text would be unparseable YAML.
        case keyWithoutOwnLine

        public var description: String {
            switch self {
            case .nonScalarKey: "a top-level key is not a scalar the editor can address"
            case .keyWithoutOwnLine: "a top-level key has no line of its own"
            }
        }
    }

    /// Why this document's frontmatter cannot be edited in place, or nil when it can be — the
    /// settled **readable-but-uneditable** rule (01-storage-format.md § Frontmatter, the
    /// byte-identical round-trip contract): a shape the surgical editor cannot key by spans
    /// still loads, reads, and renders normally, but every app write to that file refuses
    /// loudly (`BoardWriter`) rather than risk silently corrupting it.
    ///
    /// Computed at parse, from the same two facts `set`/`remove` depend on: that every
    /// top-level key is a scalar, and that every occurrence of one found a line of its own to
    /// own. Deliberately conservative — anything the span matcher could not address refuses
    /// writes, even where a cleverer editor might have coped. `init(body:)` documents have no
    /// text to preserve and are always editable.
    public let uneditableShape: UneditableShape?

    /// An empty document, for files the app is creating rather than rewriting.
    public init(body: String = "") {
        openingDelimiter = "---\n"
        closingDelimiter = "---\n"
        spans = []
        values = []
        self.body = body
        uneditableShape = nil
    }

    private init(
        openingDelimiter: String,
        closingDelimiter: String,
        spans: [Span],
        values: [KeyedValue],
        body: String,
        uneditableShape: UneditableShape?
    ) {
        self.openingDelimiter = openingDelimiter
        self.closingDelimiter = closingDelimiter
        self.spans = spans
        self.values = values
        self.body = body
        self.uneditableShape = uneditableShape
    }

    // MARK: - Parsing

    /// Reads a file. YAML is composed to validate and interpret it; the text itself is retained
    /// verbatim, so the parse only decides how the document *reads*, never how it is written.
    ///
    /// **Duplicate top-level keys: last one wins** (01-storage-format.md § Frontmatter) — a
    /// deliberate divergence from strict YAML, which rejects the file outright. The key's last
    /// occurrence supplies the value, earlier twins are invisible to every read, and each
    /// occurrence still keeps its own span, so the file round-trips byte-identically. `keys` and
    /// `fields` present that effective view: one entry per key, at the position of the occurrence
    /// that won (`a`, `b`, `a` reads as `b`, `a`) — which is also the order the file itself ends
    /// up in once `set` collapses the twins. A duplicate *nested* inside a value's own mapping is
    /// out of scope: it stays `unparseableYAML`, as does everything else libYAML rejects.
    public static func parse(_ text: String) throws(FrontmatterError) -> FrontmatterDocument {
        let lines = Self.lines(of: text)
        guard let first = lines.first, Self.isDelimiter(first) else { throw .missingOpeningDelimiter }
        guard let closingIndex = lines.dropFirst().firstIndex(where: Self.isDelimiter) else {
            throw .missingClosingDelimiter
        }

        let yamlLines = Array(lines[1 ..< closingIndex])
        let (node, placeholders) = try Self.composeToleratingDuplicateKeys(yamlLines)

        let mapping: Node.Mapping
        switch node {
        case .none:
            mapping = Node.Mapping([])
        case let .some(root):
            if let rootMapping = root.mapping {
                mapping = rootMapping
            } else if Resolver.default.resolveTag(of: root) == .null {
                mapping = Node.Mapping([])
            } else {
                throw .frontmatterNotAMapping
            }
        }

        let keys = mapping.map { pair in pair.key.string.map { placeholders[$0] ?? $0 } }
        let spans = Self.makeSpans(lines: yamlLines, keys: keys)

        return FrontmatterDocument(
            openingDelimiter: first,
            closingDelimiter: lines[closingIndex],
            spans: spans,
            values: Self.lastWinsValues(keys: keys, mapping: mapping),
            body: lines[(closingIndex + 1)...].joined(),
            uneditableShape: Self.uneditableShape(keys: keys, spans: spans)
        )
    }

    /// Composes the YAML block, tolerating repeated top-level keys.
    ///
    /// libYAML refuses a mapping with a duplicated key, but its error is informative enough to
    /// disarm: it names the duplicated keys and marks the *first* occurrence of one of them. That
    /// occurrence's key is renamed to a placeholder and the block re-composed, until nothing is
    /// duplicated. Renaming leaves the line count — and therefore every subsequent mark, and the
    /// whole span layout — identical to the original, so the composed mapping still lines up with
    /// the file's lines one for one; the returned table maps each placeholder back to the key it
    /// stands for. Anything that is not a duplicate at column 0 (a nested duplicate included) is
    /// reported as `unparseableYAML`, unchanged.
    private static func composeToleratingDuplicateKeys(
        _ lines: [String]
    ) throws(FrontmatterError) -> (node: Node?, placeholders: [String: String]) {
        var work = lines
        var placeholders: [String: String] = [:]
        let prefix = Self.placeholderPrefix(avoiding: lines.joined())

        while true {
            do {
                return (try Yams.compose(yaml: work.joined()), placeholders)
            } catch let error as YamlError {
                guard case let .duplicatedKeysInMapping(duplicates, context) = error else {
                    throw FrontmatterError.unparseableYAML(message: "\(error)", line: Self.fileLine(of: error))
                }
                // Marks are 1-based and count only the YAML block, which the rename never resizes.
                let index = context.mark.line - 1
                guard placeholders.count < lines.count, work.indices.contains(index),
                      let key = duplicates.first(where: { Self.lineStartsKey(work[index], key: $0) })
                else {
                    throw FrontmatterError.unparseableYAML(message: "\(error)", line: Self.fileLine(of: error))
                }
                let placeholder = prefix + String(placeholders.count)
                let line = work[index]
                placeholders[placeholder] = key
                work[index] = placeholder + String(line.dropFirst(Self.keyText(of: line, key: key).count))
            } catch {
                throw FrontmatterError.unparseableYAML(message: "\(error)", line: nil)
            }
        }
    }

    /// A key stem that appears nowhere in the file, so a renamed duplicate can never collide with
    /// a key the author actually wrote.
    private static func placeholderPrefix(avoiding text: String) -> String {
        var prefix = "__lanework_duplicate_"
        while text.contains(prefix) { prefix += "_" }
        return prefix
    }

    /// One entry per distinct key, carrying the *last* occurrence's value and sitting where that
    /// occurrence sits — the last-wins reading of a file with repeated keys.
    private static func lastWinsValues(keys: [String?], mapping: Node.Mapping) -> [KeyedValue] {
        var values: [KeyedValue] = []
        for (key, pair) in zip(keys, mapping) {
            guard let key else { continue }
            values.removeAll { $0.key == key }
            values.append(KeyedValue(key: key, value: YAMLValue(pair.value)))
        }
        return values
    }

    /// Whether the parse produced a document `set`/`remove` can edit by span, and if not, why
    /// (see `uneditableShape`). Two questions, in the order they can be answered:
    ///
    /// - A `nil` entry in `keys` is a top-level mapping key YAML resolved to something other
    ///   than a string — the editor has no name to match a line against at all.
    /// - Otherwise every occurrence must have claimed a span of its own. `makeSpans` only opens
    ///   a span on a line that literally starts the key it expects next, so a shortfall means
    ///   some occurrence lives inside a line the matcher could not key — the whole-frontmatter
    ///   flow mapping's keys, `key : value` spacing — and a `set` of it would append a second,
    ///   contradictory entry instead of rewriting the one on disk.
    /// A third fact matters beyond the two above: **no unkeyed span may carry content**. A
    /// block whose YAML resolves to an *empty* mapping can still have text on its lines —
    /// `{}`, `null`, `~` — which lands in an unkeyed span's tail because no key ever claims
    /// it. Zero keys against zero keyed spans passes both counts, yet appending a key after
    /// that text (`{}` + `schema: 1`) is unparseable YAML — so any unkeyed span holding a
    /// line that is not a comment or blank makes the document uneditable too. Comment-only
    /// and blank preambles stay editable: appending after them is exactly what `set` is for.
    private static func uneditableShape(keys: [String?], spans: [Span]) -> UneditableShape? {
        if keys.contains(where: { $0 == nil }) { return .nonScalarKey }
        if spans.filter({ $0.key != nil }).count != keys.count { return .keyWithoutOwnLine }
        if spans.contains(where: { $0.key == nil && !lines(of: $0.text).allSatisfy(isTailLine) }) {
            return .keyWithoutOwnLine
        }
        return nil
    }

    public func serialized() -> String {
        openingDelimiter + spans.map(\.text).joined() + closingDelimiter + body
    }

    // MARK: - Generic access

    /// Top-level keys in document order — the effective, last-wins view: a key written twice
    /// appears once, where its winning (last) occurrence sits (see `parse`).
    public var keys: [String] { values.map(\.key) }

    public var fields: [FrontmatterField] {
        values.map { FrontmatterField(key: $0.key, value: $0.value, rawValue: rawValue(for: $0.key) ?? $0.value.description) }
    }

    /// Every key the schema does not own — reserved names (`labels`, `due`, `template`, …) and
    /// agent overlays alike. Preserved verbatim and never interpreted.
    public var unknownFields: [FrontmatterField] {
        fields.filter { !FrontmatterKeys.schemaOwned.contains($0.key) }
    }

    public func contains(_ key: String) -> Bool {
        values.contains { $0.key == key }
    }

    public func value(for key: String) -> YAMLValue? {
        values.first { $0.key == key }?.value
    }

    /// The key's value text exactly as written, minus the `key:` header, the surrounding
    /// whitespace, and any trailing inline comment — the comment is the line's, not the value's.
    /// Read from the winning (last) occurrence, like every other read.
    public func rawValue(for key: String) -> String? {
        spans.last { $0.key == key }.map { Self.valueText(of: $0.value, key: key) }
    }

    // MARK: - Surgical edits

    /// Rewrites only this key's value lines. A new key is appended before the closing delimiter.
    ///
    /// Two rules from 01-storage-format.md § Frontmatter land here:
    ///
    /// - **Duplicates collapse.** The winning (last) occurrence is rewritten and every earlier
    ///   twin is deleted in the same call — the app owns the keys it writes, and a stale twin left
    ///   behind would resurrect itself the moment the winner were removed.
    /// - **Comments survive.** Comments on their own lines are never touched, and an inline
    ///   comment trailing the rewritten value line is re-spliced after the new value at its
    ///   original distance (`order: 3072  # keep at top` → `order: 4096  # keep at top`). That
    ///   re-splice is best-effort — see `splitInlineComment(_:keyText:)`.
    public mutating func set(_ key: String, to value: FrontmatterValue) {
        if let index = spans.lastIndex(where: { $0.key == key }) {
            spans[index].value = Self.rewritten(spans[index].value, key: key, to: value)
            for twin in (0 ..< index).reversed() where spans[twin].key == key { clearSpan(at: twin) }
        } else {
            spans.append(Span(
                key: key,
                value: "\(FrontmatterValue.emitScalar(key)): \(value.yamlText)\(appendTerminator)",
                tail: ""
            ))
        }

        let parsed = KeyedValue(key: key, value: value.parsedValue)
        if let index = values.firstIndex(where: { $0.key == key }) {
            values[index] = parsed
        } else {
            values.append(parsed)
        }
    }

    /// Deletes the key's value lines — *every* occurrence of it, so a removed key cannot be
    /// resurrected by an earlier twin. Trailing comments and blank lines survive: they read as
    /// belonging to whatever follows, and losing user text is worse than an orphaned comment.
    public mutating func remove(_ key: String) {
        for index in spans.indices.reversed() where spans[index].key == key { clearSpan(at: index) }
        values.removeAll { $0.key == key }
    }

    /// The line ending an *appended* key adopts. Rewritten lines keep their own ending
    /// (`rewritten(_:key:to:)`); an appended line has no prior ending to keep, so it follows
    /// the file's prevailing one — read off the opening delimiter, the one line every parsed
    /// document is guaranteed to have. A CRLF file stays uniformly CRLF when a stamp lands in
    /// it for the first time; "preserved per line, never normalized"
    /// (01-storage-format.md § Fractal layout ▸ Rules) extended to the line that never existed.
    private var appendTerminator: String {
        openingDelimiter.hasSuffix("\r\n") ? "\r\n" : "\n"
    }

    /// Drops one span's value lines, keeping its trailing comments and blank lines.
    private mutating func clearSpan(at index: Int) {
        if spans[index].tail.isEmpty {
            spans.remove(at: index)
        } else {
            spans[index] = Span(key: nil, value: "", tail: spans[index].tail)
        }
    }

    // MARK: - Line splitting

    private static func lines(of text: String) -> [String] {
        let parts = text.components(separatedBy: "\n")
        var lines = parts.dropLast().map { $0 + "\n" }
        if let last = parts.last, !last.isEmpty { lines.append(last) }
        return lines
    }

    private static func isDelimiter(_ line: String) -> Bool {
        var trimmed = Substring(line)
        while let last = trimmed.last, last.isWhitespace { trimmed = trimmed.dropLast() }
        return trimmed == "---"
    }

    /// Splits the YAML block into per-key spans. A line only starts a span when it opens the key
    /// the parser expects next, so an unindented value continuation (multi-line flow collections)
    /// can never be mistaken for a key. `keys` lists every top-level occurrence, duplicates
    /// included, so each twin gets its own span and the block still serializes back verbatim.
    private static func makeSpans(lines: [String], keys: [String?]) -> [Span] {
        var spans: [Span] = []
        var current: Span?
        var preamble = ""
        var expected = 0

        func flush() {
            if let current {
                spans.append(current)
            } else if !preamble.isEmpty {
                spans.append(Span(key: nil, value: "", tail: preamble))
            }
        }

        for line in lines {
            if expected < keys.count, let key = keys[expected], lineStartsKey(line, key: key) {
                flush()
                preamble = ""
                current = Span(key: key, value: line, tail: "")
                expected += 1
            } else if var span = current {
                if isTailLine(line) {
                    span.tail += line
                } else {
                    span.value += span.tail + line
                    span.tail = ""
                }
                current = span
            } else {
                preamble += line
            }
        }
        flush()
        return spans
    }

    private static func isTailLine(_ line: String) -> Bool {
        line.hasPrefix("#") || line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }

    private static func keyCandidates(_ key: String) -> [String] {
        [key, "\"\(key)\"", "'\(key)'"]
    }

    private static func lineStartsKey(_ line: String, key: String) -> Bool {
        for candidate in keyCandidates(key) {
            let prefix = candidate + ":"
            guard line.hasPrefix(prefix) else { continue }
            let rest = line.dropFirst(prefix.count)
            if rest.isEmpty || rest.first?.isWhitespace == true { return true }
        }
        return false
    }

    private static func keyText(of spanValue: String, key: String) -> String {
        keyCandidates(key).first { spanValue.hasPrefix($0 + ":") } ?? FrontmatterValue.emitScalar(key)
    }

    // MARK: - Rewriting a value line

    /// The replacement text for a span's value lines: `key: newValue`, plus whatever inline
    /// comment the old lines carried, ending the way they ended (a CRLF file stays CRLF).
    private static func rewritten(_ spanValue: String, key: String, to value: FrontmatterValue) -> String {
        let keyText = Self.keyText(of: spanValue, key: key)
        let split = Self.splitInlineComment(spanValue, keyText: keyText)
        return "\(keyText): \(value.yamlText)\(split.comment)\(split.terminator)"
    }

    /// A span's value lines split three ways: the text that carries the value, the trailing inline
    /// comment — its leading whitespace included, so it re-splices at the distance the author left
    /// — and the line's own terminator. `comment` is empty when the line has none.
    ///
    /// Both sides of the split matter. Writing re-attaches the comment after the new value;
    /// reading drops it, because a comment is not part of the value the author wrote.
    ///
    /// **Best-effort by design** (01-storage-format.md § Frontmatter). Guaranteed for the
    /// realistic case — a value written on one line as a plain, single-quoted, or double-quoted
    /// scalar — where YAML's own rule decides: `#` opens a comment only when preceded by
    /// whitespace and not inside a quoted scalar, so the `#` in `title: "a # b"` is value text and
    /// stays with the value. Pathological shapes keep the whole line as value text instead of
    /// making the editor guess: a value spanning several lines (block scalar, multi-line flow
    /// collection) is not scanned at all, and neither is a single-line flow collection whose
    /// quotes do not balance.
    private static func splitInlineComment(
        _ spanValue: String,
        keyText: String
    ) -> (value: String, comment: String, terminator: String) {
        // A CRLF pair is one `Character`, so both endings have to be named explicitly.
        var characters = Array(spanValue)
        var terminator = "\n"
        if characters.last == "\r\n" {
            characters.removeLast()
            terminator = "\r\n"
        } else if characters.last == "\n" {
            characters.removeLast()
        }
        // Only a value written on a single line is scanned; anything taller keeps no comment.
        guard !characters.contains(where: { $0 == "\n" || $0 == "\r\n" }) else {
            return (String(characters), "", terminator)
        }
        let header = Array(keyText + ":")
        guard characters.starts(with: header) else { return (String(characters), "", terminator) }

        var valueStart = header.count
        while valueStart < characters.count, isSpaceOrTab(characters[valueStart]) { valueStart += 1 }
        guard let hash = commentIndex(in: characters, valueStart: valueStart) else {
            return (String(characters), "", terminator)
        }

        var start = hash
        while start > 0, isSpaceOrTab(characters[start - 1]) { start -= 1 }
        return (String(characters[..<start]), String(characters[start...]), terminator)
    }

    /// Where this line's comment begins, or nil if it has none. The value's opening character
    /// says how much of the line is off limits: a quoted scalar is skipped whole, a flow
    /// collection is scanned with its quotes tracked, and a plain scalar cannot contain ` #` at
    /// all — that sequence is exactly what ends it.
    private static func commentIndex(in characters: [Character], valueStart: Int) -> Int? {
        guard valueStart < characters.count else { return nil }
        var index = valueStart
        switch characters[valueStart] {
        case "\"", "'":
            guard let end = endOfQuoted(characters, from: valueStart) else { return nil }
            index = end
        case "[", "{":
            return flowCommentIndex(characters, from: valueStart)
        default:
            break
        }
        while index < characters.count {
            if characters[index] == "#", index > 0, isSpaceOrTab(characters[index - 1]) { return index }
            index += 1
        }
        return nil
    }

    /// One past the closing quote of the quoted scalar starting at `start`, or nil if it does not
    /// close on this line. `\"` escapes inside double quotes, `''` inside single ones.
    private static func endOfQuoted(_ characters: [Character], from start: Int) -> Int? {
        let quote = characters[start]
        var index = start + 1
        while index < characters.count {
            if quote == "\"", characters[index] == "\\" {
                index += 2
                continue
            }
            if characters[index] == quote {
                if quote == "'", index + 1 < characters.count, characters[index + 1] == "'" {
                    index += 2
                    continue
                }
                return index + 1
            }
            index += 1
        }
        return nil
    }

    /// A flow collection on one line: a quote here really does open a quoted scalar, so a `#`
    /// inside one is value text.
    private static func flowCommentIndex(_ characters: [Character], from start: Int) -> Int? {
        var index = start
        while index < characters.count {
            switch characters[index] {
            case "\"", "'":
                guard let end = endOfQuoted(characters, from: index) else { return nil }
                index = end
            case "#" where index > 0 && isSpaceOrTab(characters[index - 1]):
                return index
            default:
                index += 1
            }
        }
        return nil
    }

    private static func isSpaceOrTab(_ character: Character) -> Bool {
        character == " " || character == "\t"
    }

    /// The value a span's lines carry, with the `key:` header, the surrounding whitespace, and any
    /// trailing inline comment taken off.
    ///
    /// Dropping the comment is strictly a read-side decision: a comment is not part of the value
    /// the author wrote, so it must not leak into a coerced reading (`title: 2048  # note` reads
    /// as `2048`), into `.malformed(raw:)`, or — worst — back out through a later `set`, which
    /// would splice the comment on a second time. The bytes on disk are untouched.
    private static func valueText(of spanValue: String, key: String) -> String {
        let keyText = Self.keyText(of: spanValue, key: key)
        let value = Self.splitInlineComment(spanValue, keyText: keyText).value
        let header = keyText + ":"
        guard value.hasPrefix(header) else {
            return value.trimmingCharacters(in: .whitespacesAndNewlines)
        }
        return value.dropFirst(header.count).trimmingCharacters(in: .whitespacesAndNewlines)
    }

    private static func fileLine(of error: YamlError) -> Int? {
        // +1 for the opening delimiter line, which is not part of the YAML handed to Yams.
        switch error {
        case let .scanner(_, _, mark, _), let .parser(_, _, mark, _), let .composer(_, _, mark, _):
            mark.line + 1
        case let .duplicatedKeysInMapping(_, context):
            context.mark.line + 1
        default:
            nil
        }
    }
}

/// The keys the schema owns (01-storage-format.md § Frontmatter). Everything else is an
/// unknown key: round-tripped untouched, never interpreted.
public enum FrontmatterKeys {
    public static let schema = "schema"
    public static let title = "title"
    public static let order = "order"
    public static let width = "width"

    /// **A lane folded to a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes; 01-storage-format.md
    /// § Frontmatter's lane table) — `collapsed: true`, and nothing else.
    ///
    /// Document state exactly like `width`, deliberately rather than window state: the files *are* the
    /// board, so an agent folds a lane away by writing one key and the app follows on the next reload.
    ///
    /// **Only ever written as `true`, and removed to expand** (the remove-at-default family beside the
    /// empty rename's `title`, the None well's `background`, and a one-unit `width`): an absent key is
    /// the default reading, so a `collapsed: false` the app wrote would be noise a default lane has to
    /// carry. A hand-written `false` is legal, reads as expanded, and is preserved until the app itself
    /// next edits the key.
    public static let collapsed = "collapsed"

    /// **A card's hero image** (03-board-ui.md § Card face ▸ Hero image; 01-storage-format.md
    /// § Frontmatter's card table) — `hero: sketch.png`, a **bare filename** resolving inside that
    /// card's own `attachments/` folder.
    ///
    /// No paths and no URLs, which is the whole of the key's grammar and the reason its reading is
    /// stricter than `background`'s `image` subkey: that one names a file anywhere under the board
    /// root and reads as the path it was written as, while this one names one of the card's own
    /// attachments — the files the app already lists, relocates and moves with the card — so a value
    /// carrying a separator is not a hero image spelled awkwardly, it is a value with no reading at
    /// all (`FrontmatterDocument.hero`).
    ///
    /// **Opt-in, and written from exactly one control**: the card window attachment row's "Set as
    /// Hero" / "Remove Hero" (re-ruled 2026-08-09, promoting the key's own "future work" note the day
    /// it landed; `BoardStore.setHero(_:onCard:on:)`). The row can only name a file the card already
    /// has, which is the key's grammar enforced by the surface rather than validated after the fact;
    /// hand-writing and agent-writing stay exactly as legal as they were. It joins `schemaOwned` for
    /// the reason it always did — it is Lanework's to interpret, not an unknown key riding along.
    public static let hero = "hero"

    /// **A card's labels** (01-storage-format.md § Frontmatter's card table — the amendment this key's
    /// activation owes; `CardLabels`, `FrontmatterDocument.labels`) — a YAML list of names:
    /// `labels: ["bug", "ui"]`.
    ///
    /// ### It used to be reserved, and that reversal is the design event
    ///
    /// Through 2026-08-09 this was one of the **reserved tracker keys** beside `assignees`, `due`,
    /// `remote` and `remote-state` (01 § Enhanced schema): "the pathfinder shipped them natively; the
    /// rewrite reserves them instead", preserved verbatim, never interpreted, and shown only as an
    /// anonymous row in the card window's Details section like any other unknown key. The owner's cards
    /// ("in card window sidebar add ability to manage card's labels", "add labels submenu to card
    /// context menu") claim it for **first-party use**, which retires the reservation for this one name
    /// — `assignees`, `due` and the two `remote` keys are untouched and stay exactly as reserved as they
    /// were.
    ///
    /// **It therefore joins `schemaOwned`**, which is the whole of what stops the Details section
    /// drawing a second, raw copy of a field the sidebar now renders properly (`CardDetails.rows`
    /// subtracts exactly that set). `hero`'s precedent, and for `hero`'s reason: it is Lanework's to
    /// interpret, not an unknown key riding along.
    ///
    /// ### The grammar
    ///
    /// **Read leniently, written canonically** — the shape of every lenient field here, one collection
    /// up. A sequence reads as its string entries in file order; a bare scalar coerces to a
    /// one-element list (`labels: bug`); a mapping has no list reading at all and is `.malformed`.
    /// Names are **case-preserving for display and case-insensitively unique within a card**, and
    /// entries the reading cannot make a name of are preserved on the way out but never rendered
    /// (`CardLabels`).
    ///
    /// **Written as a flow list, in the user's own order** — no auto-sort: the order is the order of
    /// addition, which is a thing the user arranged and the app has no business restating
    /// alphabetically. **Removing the last label removes the key**, the remove-at-default family's rule
    /// beside `collapsed`'s expand and the None well's `background`: an absent key is the no-labels
    /// reading, so `labels: []` written by the app would be noise every unlabelled card had to carry.
    public static let labels = "labels"

    public static let created = "created"
    public static let modified = "modified"
    public static let modifiedBy = "modified-by"
    public static let deleted = "deleted"
    public static let background = "background"
    public static let icon = "icon"
    public static let iconColor = "iconColor"

    /// **The `background` mapping's subkeys** (01-storage-format.md § Frontmatter — the field is a
    /// mapping and nothing else, ruled 2026-08-06): `{color: "#112233", image: sunset.jpg}`, either
    /// half absent, and any other subkey an author writes tolerated and carried through
    /// (`FrontmatterDocument.setStyleValue`). A bare scalar has no reading at all.
    ///
    /// **Named here without joining `schemaOwned`**, and for a plainer reason than `remote`'s: that
    /// set is what `unknownFields` subtracts from the document's *top-level* keys, and these two are
    /// inside one. A top-level `color:` or `image:` is somebody else's key and stays an unknown one.
    public enum Background {
        public static let color = "color"
        public static let image = "image"
    }

    /// The object's kind — `board`, `lane`, `card` (01-storage-format.md § Frontmatter ▸ Common to
    /// all levels, re-ruled 2026-07-29). Written at creation of every object, backfilled on touch
    /// when absent (`IntegrityRules.healOnTouch`), and never stripped.
    public static let kind = "kind"

    /// The reserved tracker keys — `remote` (board, card) and `remote-state` (lane) — of the enhanced
    /// schema's future connectors (01-storage-format.md § Enhanced schema). **`labels` left this
    /// family on 2026-08-09** when the owner claimed it for first-party use; see that key above.
    ///
    /// **Named here without joining `schemaOwned`**, which is the ruling rather than an oversight: 01
    /// says the app "treats reserved keys as ordinary unknown keys (preserved verbatim, invisible in
    /// the UI)", and `schemaOwned` is exactly the set `unknownFields` subtracts. Nothing about their
    /// ordinary posture changes.
    ///
    /// They are named at all because of the one act that does touch them: **an item-level copy severs
    /// tracker identity** (§ Fractal layout ▸ Rules, ruled 2026-07-29) — "two local objects must never
    /// both claim to be the same remote object" — so every folder a paste, an ⌥-drag duplicate, a
    /// cross-board copy or a template instantiation materializes drops both keys, at every level.
    /// Whole-board forks (Duplicate, Save as Template) carry them verbatim, as always.
    public static let remote = "remote"
    public static let remoteState = "remote-state"

    /// **A comment's self-reported author** (01-storage-format.md § Enhanced schema, the
    /// `kind: comment` field table): "lenient — self-reported *content*, not overlay: **unlike
    /// `modified-by` it survives app writes**; the app writes the macOS account's full name, agents
    /// write their own, tracker sync writes the remote author verbatim, and missing renders
    /// unattributed".
    ///
    /// **Named here without joining `schemaOwned`**, `remote`'s precedent and its reason turned
    /// around: the key belongs to the comment field table and to no other kind, so on a board, a lane
    /// or a card it is an ordinary unknown key — and `schemaOwned` is exactly the set the card
    /// window's Details section subtracts. Listing it would *hide* a hand-added `author:` from the one
    /// surface that exists to show hand-added keys; a comment has no such surface to be affected
    /// either way.
    ///
    /// Nothing clears it, anywhere: `updateIndex` clears `modified-by` and only that.
    public static let author = "author"

    public static let schemaOwned: Set<String> = [
        schema, title, order, width, collapsed, hero, labels, created, modified, modifiedBy, deleted,
        background, icon, iconColor, kind,
    ]
}
