import Foundation

/// **The rules of a card's `labels` list** — the pure rulebook behind the read in
/// `FrontmatterFields.swift` and the write below (`FrontmatterKeys.labels` has the key's own story,
/// including why it stopped being a reserved tracker key on 2026-08-09).
///
/// Everything here is a static function over values: no document, no filesystem, no store. The two
/// surfaces that manage labels — the card window's sidebar section and the card context menu's
/// submenu — both reduce to `toggling`/`adding`/`removing` over a `[String]`, so neither of them can
/// invent a second answer to "does this card already have that label?".
public enum CardLabels {

    // MARK: - One name

    /// A name as the app stores it: **trimmed of surrounding whitespace, and never empty**.
    ///
    /// `nil` is "there is no name here" — an empty string, or a string that is nothing but spaces.
    /// That is not a label anybody meant: it would render as a blank chip, match nothing in the
    /// used-labels universe, and be unremovable by clicking the thing it does not draw. The empty
    /// rename's own rule (`title`: a name that trims to nothing writes no key at all) one collection
    /// down.
    ///
    /// **Interior whitespace is left exactly as typed.** `"needs review"` is a perfectly good label
    /// and the app has no business folding it to one word or to a hyphen — the schema stores names,
    /// not identifiers.
    public static func normalized(_ text: String) -> String? {
        let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
        return trimmed.isEmpty ? nil : trimmed
    }

    /// The comparison key: a name case-folded, so `Bug` and `bug` are one label.
    ///
    /// **Case-preserving display, case-insensitive uniqueness** (the owner's ruling). The fold is
    /// locale-independent on purpose — `ItemID.canonicalValue`'s posture, for its reason: this decides
    /// identity, and an identity that changed with the user's region would make the same two files
    /// mean different things on two machines.
    public static func canonical(_ name: String) -> String {
        name.lowercased()
    }

    /// Whether a case-insensitively equal name is already in the list.
    public static func contains(_ name: String, in list: [String]) -> Bool {
        guard let name = normalized(name) else { return false }
        let key = canonical(name)
        return list.contains { canonical($0) == key }
    }

    // MARK: - One list

    /// The list with case-insensitive duplicates collapsed, **first spelling wins**, order otherwise
    /// preserved.
    ///
    /// The reading applies this, so a hand-written `labels: [Bug, bug]` shows one chip rather than two
    /// that cannot be told apart — and the next app write to the key lands the collapsed form, which is
    /// the on-touch heal every other lenient field already performs by rewriting what it read.
    ///
    /// *First* spelling rather than last, unlike the duplicate-key rule one level up
    /// (`FrontmatterDocument.parse`'s last-wins): a key written twice is a file with two answers and the
    /// later one is the author's correction, while a list is one value whose members are in an order the
    /// author chose — so the first occurrence is the one that holds its place.
    public static func deduplicated(_ list: [String]) -> [String] {
        var seen: Set<String> = []
        var result: [String] = []
        for entry in list {
            guard let name = normalized(entry), seen.insert(canonical(name)).inserted else { continue }
            result.append(name)
        }
        return result
    }

    /// `name` appended to the list, or the list unchanged when a case-insensitive twin is already
    /// there.
    ///
    /// **Appended, never sorted in** — the order of addition is the user's arrangement (the key's own
    /// no-auto-sort rule), and **the existing spelling wins** a case clash: adding `Bug` to a card that
    /// already carries `bug` is a no-op rather than a silent respelling, because the user is adding a
    /// label they already have and nothing about that gesture asks to rename it.
    public static func adding(_ name: String, to list: [String]) -> [String] {
        guard let name = normalized(name), !contains(name, in: list) else { return deduplicated(list) }
        return deduplicated(list) + [name]
    }

    /// The list without any case-insensitive match for `name`.
    public static func removing(_ name: String, from list: [String]) -> [String] {
        guard let name = normalized(name) else { return deduplicated(list) }
        let key = canonical(name)
        return deduplicated(list).filter { canonical($0) != key }
    }

    /// `removing` when the label is there, `adding` when it is not — the context menu's row and the
    /// dialog's checkbox both press exactly this.
    public static func toggling(_ name: String, in list: [String]) -> [String] {
        contains(name, in: list) ? removing(name, from: list) : adding(name, to: list)
    }

    // MARK: - Reading a parsed value

    /// One sequence entry's reading as a label, or `nil` when it has none.
    ///
    /// **Only a YAML string is a name**, which is a deliberate narrowing of the scalar-coercion family
    /// every other lenient field belongs to (`title: 2048` reads as `"2048"`). Two reasons, and the
    /// owner's ruling says the same thing in one sentence ("non-string entries preserved untouched but
    /// not rendered"):
    ///
    /// - A single-valued field has one value, so coercing it is the only way to have a reading at all.
    ///   A list has members, and a member that is not a name can simply be **kept** — which is a better
    ///   outcome than guessing, because nothing is lost either way.
    /// - `labels: [2026, ui]` is far more likely to be somebody's structured entry than a card tagged
    ///   with a number, and the app inventing the string `"2026"` for it would make that entry
    ///   unremovable-by-intent: the chip would say one thing and the file another.
    ///
    /// **The reading a `setLabels(_:)` will leave behind** — `nil` when the write removes the key.
    ///
    /// It exists because an undo step has to declare the after-value its write produced
    /// (`ExpectedField.labels`), and for this key that is not simply the list handed in: an empty list
    /// removes the key, whose reading is an *absence* rather than an empty list — unless entries the
    /// reading cannot name are holding the key open, in which case the reading really is `[]`. Two
    /// different bytes, two different expectations, and a step that declared the wrong one would skip
    /// itself the first time somebody took the last label off a card.
    ///
    /// A pure function rather than a re-read of the written document, so the prediction and the write
    /// are the same rule stated once — `FrontmatterDocument.setLabels` branches on exactly these two
    /// facts.
    public static func readingAfterWrite(_ names: [String], preservingEntries: Bool) -> [String]? {
        let names = deduplicated(names)
        guard !names.isEmpty || preservingEntries else { return nil }
        return names
    }

    /// Also `nil` for a null, a nested sequence, a mapping, and anything that trims to nothing.
    static func name(of entry: YAMLValue) -> String? {
        guard case let .string(text) = entry else { return nil }
        return normalized(text)
    }

    /// A parsed `labels` value's reading, or `nil` when it has **none at all** — the transform behind
    /// `FrontmatterDocument.labels`'s `.valid`/`.malformed` split.
    ///
    /// - a **sequence** reads as its string entries, deduplicated, in file order (`[]` for a list with
    ///   no names in it, which is a perfectly good "no labels" and not a failure);
    /// - a **bare scalar** coerces to a one-element list — `labels: bug` is a card with one label, the
    ///   single-value shape an author or an agent reaches for first, and refusing it would make the
    ///   most forgivable spelling the one shape that renders nothing;
    /// - a **mapping** has no list reading and is the one shape that lands `.malformed`, rendering as
    ///   no labels and leaving the coerce tier's trace.
    ///
    /// A non-string *scalar* at the top level (`labels: 3`) is deliberately **not** coerced, for
    /// `name(of:)`'s reason: it reads as no list, so it is malformed and preserved rather than becoming
    /// a card labelled "3".
    static func reading(of value: YAMLValue) -> [String]? {
        switch value {
        case let .sequence(entries):
            return deduplicated(entries.compactMap(name(of:)))
        case let .string(text):
            return normalized(text).map { [$0] } ?? []
        default:
            return nil
        }
    }
}

// MARK: - The write side

/// **The write side of `labels`** — the read side is `FrontmatterDocument.labels`
/// (FrontmatterFields.swift) and the rules are `CardLabels` above. `BackgroundField.swift`'s shape and
/// its reasoning, one collection over.
extension FrontmatterDocument {

    /// The entries of the current `labels` value the reading could make no name of — what
    /// `setLabels(_:)` carries through untouched.
    ///
    /// Empty for every shape that is not a sequence: a bare scalar has one entry and the reading
    /// already made a name (or nothing) of it, and a mapping is the malformed shape a forward write
    /// **replaces** outright — the same malformed-value-cleared posture `icon` has ("choosing any well
    /// replaces it", 03-board-ui.md § Styling ▸ Controls), and the right one here, since the reader
    /// could not make a list of it either.
    var preservedLabelEntries: [YAMLValue] {
        guard case let .sequence(entries)? = value(for: FrontmatterKeys.labels) else { return [] }
        return entries.filter { CardLabels.name(of: $0) == nil }
    }

    /// Writes the card's labels — the **one** mutation every labels surface goes through.
    ///
    /// ### Canonical form
    ///
    /// A single-line **flow sequence** of double-quoted names, in the order given:
    /// `labels: ["bug", "needs review"]`. Flow because the span editor rewrites a key's value with one
    /// line's worth of text and `FrontmatterValue` has no sequence case to rewrite it with — the same
    /// `.raw` escape and the same narrow yield of the verbatim promise `setStyleValue` makes for
    /// `background`'s mapping, documented at length in `BackgroundField.swift`. Double-quoted for that
    /// file's reason exactly: `,` and `]` end a plain scalar in flow context, so a label with a comma
    /// in it would otherwise break the collection it is written into.
    ///
    /// **No sort.** The caller's order is the file's order, because the caller's order is the user's:
    /// labels accumulate in the sequence they were applied, and an alphabetical rewrite on every touch
    /// would be the app rearranging something the user arranged.
    ///
    /// ### The empty case removes the key
    ///
    /// Removing the last label removes `labels` outright rather than writing `labels: []` — the
    /// remove-at-default family (`collapsed`'s expand, the None well's `background`, a one-unit
    /// `width`): an absent key is exactly the no-labels reading, and an empty list on every unlabelled
    /// card would be noise the format does not need. The one exception is a list that still holds
    /// preserved non-name entries, which keeps the key so those entries survive.
    ///
    /// ### What survives, and what does not
    ///
    /// Entries with no name reading are carried through **at the tail**, after the names. Their
    /// *values* survive; their spelling does not — a block sequence collapses to flow form, quoting is
    /// normalized, and an entry's own inline comment is lost with the line it sat on. Tail rather than
    /// in place because a name list has no positions worth preserving *for the names* (the user just
    /// rearranged them by definition), and interleaving preserved entries back among them would make
    /// the user's order depend on somebody else's structured data.
    public mutating func setLabels(_ names: [String]) {
        let names = CardLabels.deduplicated(names)
        let preserved = preservedLabelEntries
        guard !names.isEmpty || !preserved.isEmpty else {
            remove(FrontmatterKeys.labels)
            return
        }
        let entries = names.map { FrontmatterValue.emitQuoted($0) } + preserved.map(Self.flowText)
        set(FrontmatterKeys.labels, to: .raw("[" + entries.joined(separator: ", ") + "]"))
    }
}
