import Foundation

// MARK: - ExpectedField

/// One field a step's write left holding a known value — the unit 13-native-undo.md ▸ Rules'
/// **field-level** predicate compares.
///
/// "Each step registers both sides of its write anyway (the before-value is the inverse; the
/// after-value is what its write set), so validation compares the targeted field's current value
/// against the expected after-value" (settled, ruled 2026-07-27). These cases are therefore exactly
/// the fields the app's own inverses write and no others: a step declares what *it* set, never what
/// it merely read, so a foreign edit to a field this gesture never touched cannot skip anything.
///
/// **`nil` means the key is absent**, which is a real after-value throughout this app rather than a
/// missing one: an emptied rename removes `title`, a width of one unit removes `width`, and the None
/// well removes `background` (the remove-at-default family). A **malformed** value on disk matches
/// neither an absence nor a value — it is not something this app writes, so finding one is finding
/// somebody else's edit.
public enum ExpectedField: Sendable, Equatable {

    /// `title` — renames at every level.
    case title(String?)

    /// `order` — every reorder, every move's landing rank, and the rank half of a drag-restore.
    case order(Double)

    /// `width` — the lane resize. `nil` is the one-unit default, whose key the write removes.
    case width(Int?)

    /// `collapsed` — the lane fold (03-board-ui.md § Lane ▸ Collapsed lanes). `nil` is the expanded
    /// default, whose key the write removes, so the pair of after-values a collapse and its inverse
    /// declare is exactly `true` and `nil` — the app never writes `false`
    /// (`FrontmatterKeys.collapsed`), and a hand-written one therefore stales the step, which is the
    /// same reading a malformed value gets: somebody else edited the key.
    case collapsed(Bool?)

    /// `background` — the styling gesture's colour dimension.
    case background(String?)

    /// The `background` mapping's **`image` subkey** — the generated-background gesture's other half
    /// (`BoardStore.applyGeneratedBackground`).
    ///
    /// Its own case rather than a second reading of `.background`, because they are two independent
    /// values under one key: a board can have its colour changed from the wells while its image
    /// stays, and the step that wrote the image must not stale because somebody picked a colour
    /// afterwards. `nil` is the absent subkey, exactly as everywhere else here.
    case backgroundImage(String?)

    /// `hero` — the card's banner picture, named by the attachment row's "Set as Hero" / "Remove
    /// Hero" (`BoardStore.setHero(_:onCard:on:)`, ruled 2026-08-09).
    ///
    /// Its own case beside `.icon` rather than a reading of it, for `.backgroundImage`'s reason: they
    /// are independent values on one card, and a step that named the hero must not stale because
    /// somebody picked a symbol afterwards. `nil` is the removed key — Remove Hero — exactly as
    /// everywhere else here.
    case hero(String?)

    /// `labels` — the card's label list, whole (`BoardStore.setLabels(_:onCard:on:)`, the key's
    /// 2026-08-09 activation).
    ///
    /// **The whole list, not one label**, which is what makes the comparison right: a labels write
    /// rewrites the key's entire value, so the after-value it declares is the entire value it wrote. A
    /// per-label expectation would let a step that added `bug` cross safely onto a card somebody else
    /// has since retagged completely, which is exactly the foreign edit staleness exists to catch.
    ///
    /// `nil` is the removed key — what taking the last label off leaves behind (the remove-at-default
    /// family; `FrontmatterKeys.labels`). A card whose list has been emptied but whose key survives for
    /// preserved non-name entries reads `.valid([])` and matches `[]`, not `nil`: those are different
    /// bytes and the step knows which one it wrote.
    case labels([String]?)

    /// `icon` — the styling gesture's symbol dimension.
    case icon(String?)

    /// `iconColor` — the symbol's tint, the styling gesture's third dimension since the board
    /// popover's symbol picker grew its colour row (2026-08-07; previously "schema yes, control
    /// no"). `nil` is the removed key — the None well — exactly as everywhere else here.
    case iconColor(String?)

    /// The body span, **byte for byte** — the Edit session's step, and the one inverse in the app
    /// whose fidelity is not field-level (13: "body steps compare bytes").
    case body(String)

    /// Which field this is, ignoring the value it carries — the key a fold merges on
    /// (`CardWindowUndo`): two writes to `background` inside one card-window session are one field
    /// with a first and a last value, while a write to `background` and one to `icon` are two.
    var kind: Kind {
        switch self {
        case .title: .title
        case .order: .order
        case .width: .width
        case .collapsed: .collapsed
        case .background: .background
        case .backgroundImage: .backgroundImage
        case .hero: .hero
        case .labels: .labels
        case .icon: .icon
        case .iconColor: .iconColor
        case .body: .body
        }
    }

    /// The field names, as a comparable value — deliberately not the `String` keys, which are
    /// `FrontmatterKeys`' business and would tie a fold to the file format.
    enum Kind: Hashable, Sendable {
        case title
        case order
        case width
        case collapsed
        case background
        case backgroundImage
        case hero
        case labels
        case icon
        case iconColor
        case body
    }
}

// MARK: - HistoryAnchor

/// **What an expectation is an expectation *about*** — a folder fixed when the step was registered,
/// or a card identity resolved afresh every time the step is crossed.
///
/// ### Board gestures anchor by path
///
/// A move, a reorder, a delete, a create, a board-issued restyle: the gesture *is* about where an
/// item sits, its inverse is the move back, and the path it names is the path it wrote to. The
/// container check rides in that path (`HistoryExpectation`), and for these steps that is exactly the
/// reading wanted — a foreign restore out of the trash *should* stale a delete step's undo.
///
/// ### Session steps anchor by card identity
///
/// "**Session steps anchor by card identity, never by path**" (13-native-undo.md ▸ Rules, ruled
/// 2026-07-31): "the coarse step — and the window's fine steps it folds — stores the card's UUID plus
/// expected values, and apply-time validation resolves the card's *current* folder exactly the way
/// the window itself always resolves its card (the per-snapshot UUID walk; `writeCardBody` already
/// resolves trash locations on purpose). A tracked relocation — a lane move mid-session or after
/// close, a trash move — therefore never stales the step; only genuine content changes do, which is
/// what the validation exists to catch."
///
/// The defect that ruled it: every component of a card window's session carried a **lane-bearing**
/// folder path, so one board-side lane move — a drag on the board while the window sat open, or any
/// move after it closed — staled all of them at once and the whole session step skipped, though
/// nothing about the card's *content* had changed.
///
/// ### The card-relative cases are a closed vocabulary
///
/// Four, and they are exactly the folders a card window's gestures write to: the card itself, one
/// posted comment, one deleted comment, the composer's draft. Cases rather than a card id plus a
/// relative path list, so the path grammar stays in one place — resolution calls `CommentThread`'s own
/// folder helpers, and a step can never disagree with the thread reader about where a comment lives.
public enum HistoryAnchor: Sendable, Hashable {

    /// A folder, as the gesture resolved it at registration time.
    case path(URL)

    /// A card's own folder, wherever the card is now.
    case card(ItemID)

    /// `<card>/comments/<id>/` — one posted comment.
    case comment(ItemID, inCard: ItemID)

    /// `<card>/comments/.trash/<id>/` — one deleted comment, undo's backing store.
    case trashedComment(ItemID, inCard: ItemID)

    /// `<card>/comments/.draft/` — the composer's backing file.
    case commentDraft(inCard: ItemID)
}

extension HistoryAnchor {

    /// Where this anchor points **now**, or `nil` when it points nowhere.
    ///
    /// **The card walk is `writeCardBody`'s, deliberately** (`BoardStore.cardBodyTarget`): the one
    /// resolution in the app that spans both containers, because 05-card-window.md ▸ Deletion &
    /// lifecycle already needs a card window's own flush to reach a card that was moved into the trash
    /// out from under it. 13 names that walk by hand as the one a session step resolves through, so a
    /// trash move is a tracked relocation here rather than a vanishing.
    ///
    /// `nil` is "the card resolves nowhere — purged, or moved out of the board", which 13 calls "the
    /// honest skip". The snapshot is the store's own, one reload behind the app's own writes exactly as
    /// the card window's is: the window resolves its card this way on every gesture, so a step that
    /// resolved any *fresher* would be answering a question the window itself never asks.
    public func folder(under root: URL, in snapshot: BoardModel) -> URL? {
        switch self {
        case let .path(url):
            url
        case let .card(id):
            Self.cardFolder(id, under: root, in: snapshot)
        case let .comment(id, card):
            Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.commentFolder(id, inCard: $0) }
        case let .trashedComment(id, card):
            Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.trashedCommentFolder(id, inCard: $0) }
        case let .commentDraft(card):
            Self.cardFolder(card, under: root, in: snapshot).map { CommentThread.draftFolder(inCard: $0) }
        }
    }

    private static func cardFolder(_ id: ItemID, under root: URL, in snapshot: BoardModel) -> URL? {
        BoardStore.cardBodyTarget(id, in: snapshot)?.folder(under: root)
    }

    /// The folder a `.path` anchor names, and `nil` for every identity anchor — the resolver for a
    /// caller with no board to resolve against, which in the app is nobody and in a test is the
    /// shortest way to check a path-anchored expectation.
    public var literalPath: URL? {
        guard case let .path(url) = self else { return nil }
        return url
    }
}

// MARK: - HistoryExpectation

/// What one folder must currently hold for a step to be safe to cross — the state that step's write
/// left it in.
///
/// Two halves, both of them 13's: **existence** ("target folder gone ... → the step is skipped"),
/// and the **field-level** comparison above. A step carries one of these per item it touched, so a
/// multi-card move validates three targets and a single rename validates one — which is the whole of
/// "a foreign change to an unrelated item must not skip anything": an item no step named is an item
/// no expectation mentions.
///
/// ### The container side rides in the folder path
///
/// **The folder's *path* is the parent check**, and since the trash was materialized that check is
/// also the container check (03-board-ui.md § Trash, resettled 2026-07-28). A delete step's undo
/// expects its card at `<root>/.trash/<id>`; a foreign restore moves the folder out, so nothing is
/// at that path and the existence half already answers "the card is not in the trash any more".
/// The mirror holds: the redo expects it back at `<root>/<lane>/<id>`, where a foreign re-delete
/// leaves nothing. That is why `Presence` is a two-case answer rather than the tombstone era's
/// three-way live/tombstoned/absent reading of a `deleted:` key — there is no key to read, and no
/// ancestor to walk to find one.
///
/// **A card-anchored expectation makes the same check about a path it resolves rather than
/// remembers**, which is the whole of the difference: a comment's anchor still names
/// `comments/.trash/<id>` versus `comments/<id>`, so the container reading above is untouched, while
/// the card's own lane — the part of the path no session gesture is about — stops being asserted.
/// Which of the two anchorings a step uses is `HistoryAnchor`'s subject and the one thing this type
/// stayed neutral about: everything below reads the folder the anchor resolves to, identically either
/// way.
public struct HistoryExpectation: Sendable, Equatable {

    /// What the item this step wrote to is addressed by — a path for a board gesture, a card identity
    /// for a session step (`HistoryAnchor`).
    public let anchor: HistoryAnchor

    /// Whether the item should be there.
    public let presence: Presence

    /// The fields the step's write set, with the values it set them to. Empty for a step whose
    /// whole subject *is* existence — a create, a lane delete.
    public let fields: [ExpectedField]

    /// Whether anything is at this path.
    public enum Presence: Sendable, Equatable {
        /// There, with a readable `index.md`. Which container that is, is the path's own answer.
        case present

        /// Not there at all: the folder is gone. What an undone create and an undone lane delete
        /// leave, and what a redone one expects to find before putting it back.
        case absent
    }

    public init(anchor: HistoryAnchor, presence: Presence, fields: [ExpectedField]) {
        self.anchor = anchor
        self.presence = presence
        self.fields = fields
    }

    /// The item is where this anchor points and its fields say what the step set them to.
    public static func present(_ anchor: HistoryAnchor, _ fields: ExpectedField...) -> HistoryExpectation {
        HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
    }

    /// The same, for a caller whose field list is computed — the styling gesture's, which varies per
    /// dimension. A label rather than a second variadic, so `.present(anchor)` stays unambiguous.
    public static func present(_ anchor: HistoryAnchor, fields: [ExpectedField]) -> HistoryExpectation {
        HistoryExpectation(anchor: anchor, presence: .present, fields: fields)
    }

    /// Nothing is where this anchor points.
    public static func absent(_ anchor: HistoryAnchor) -> HistoryExpectation {
        HistoryExpectation(anchor: anchor, presence: .absent, fields: [])
    }

    /// The path-anchored trio, spelled with the folder a board gesture already holds — the shape every
    /// call site outside a card window uses.
    public static func present(_ folder: URL, _ fields: ExpectedField...) -> HistoryExpectation {
        HistoryExpectation(anchor: .path(folder), presence: .present, fields: fields)
    }

    public static func present(_ folder: URL, fields: [ExpectedField]) -> HistoryExpectation {
        HistoryExpectation(anchor: .path(folder), presence: .present, fields: fields)
    }

    public static func absent(_ folder: URL) -> HistoryExpectation {
        HistoryExpectation(anchor: .path(folder), presence: .absent, fields: [])
    }
}

// MARK: - HistoryStaleness

/// The staleness predicate: does the board still look the way this step's write left it?
/// (13-native-undo.md ▸ Rules ▸ staleness validation.)
///
/// ### It reads disk, not the snapshot
///
/// 13 says "re-checks its target against the current snapshot at ⌘Z time", and *current* is the
/// load-bearing word: the store's `snapshot` is by construction one reload behind every write the
/// app makes (the one-way flow means a write is only visible once the watcher round-trips it), so
/// validating against it would make a second ⌘Z pressed inside the debounce window compare against a
/// board that still shows the first one's *pre*-state — every rapid undo run would false-skip. Disk
/// is what "current" can honestly mean at the instant a step is crossed, and it is also what an
/// inverse is about to write to.
///
/// ### Lazily, never eagerly
///
/// Nothing here is called by the watcher, the ledger, or any background sweep: "invalidation is lazy
/// (settled — ruled 2026-07-27): staleness is discovered at ⌘Z time, never by background pruning ...
/// The stack always looks full". This type has exactly one caller, `BoardStore.cross`, one line
/// before the inverse would have been written.
///
/// ### It needs no board root
///
/// The tombstone era's liveness half walked a folder's ancestors looking for a `deleted:` key, and
/// needed the root to know where to stop. Materializing the trash removed the walk: an item's
/// container is its path, and a path is checked by asking the filesystem whether anything is there.
///
/// ### It needs a *resolver*, though — one, injected
///
/// A session step's expectations name a card rather than a folder (`HistoryAnchor`, ruled
/// 2026-07-31), so somebody has to turn the anchor into the path this reads. That somebody is the
/// board — `BoardStore.folder(for:)`, the store's own snapshot walk — handed in as a closure rather
/// than reached for, which keeps this type what it has always been: a predicate over disk with no
/// board, no root and no state of its own.
public enum HistoryStaleness {

    /// Whether every target a step named still holds what that step left there.
    ///
    /// - Parameter resolve: where each anchor points now. **An anchor that resolves nowhere fails**,
    ///   whatever its presence half says: "a card that resolves nowhere (purged, or moved out of the
    ///   board) is the honest skip" (13 ▸ Rules), and reading an unresolvable card's `.absent`
    ///   expectations as satisfied would let half a step through on a card that has left.
    public static func isCurrent(
        _ expectations: [HistoryExpectation],
        resolvedBy resolve: (HistoryAnchor) -> URL?
    ) -> Bool {
        expectations.allSatisfy { expectation in
            guard let folder = resolve(expectation.anchor) else { return false }
            return isCurrent(expectation, at: folder)
        }
    }

    /// One target's answer, at the folder its anchor resolved to.
    ///
    /// A file that cannot be read or parsed fails a `.present` expectation: an `index.md` somebody
    /// has just broken is not one holding this step's after-value, and the honest reading of "the
    /// field no longer holds it" covers a field that can no longer be read at all.
    public static func isCurrent(_ expectation: HistoryExpectation, at folder: URL) -> Bool {
        guard expectation.presence != .absent else {
            return !FileManager.default.fileExists(atPath: folder.path)
        }
        guard let document = index(at: folder) else { return false }
        return expectation.fields.allSatisfy { matches($0, in: document) }
    }

    // MARK: Fields

    static func matches(_ field: ExpectedField, in document: FrontmatterDocument) -> Bool {
        switch field {
        case let .title(expected): equal(document.title, expected)
        case let .order(expected): document.order.value == expected
        case let .width(expected): equal(document.width, expected)
        case let .collapsed(expected): equal(document.collapsed, expected)
        case let .background(expected): equal(document.background, expected)
        case let .backgroundImage(expected): equal(document.backgroundImage, expected)
        case let .hero(expected): equal(document.hero, expected)
        case let .labels(expected): equal(document.labels, expected)
        case let .icon(expected): equal(document.icon, expected)
        case let .iconColor(expected): equal(document.iconColor, expected)
        case let .body(expected): document.body == expected
        }
    }

    /// A present value matches a present expectation by value; an absence matches an absence.
    ///
    /// **A malformed field matches nothing**, deliberately: `FieldValue.malformed` is a shape this
    /// app never writes (`background: [a, b]` is somebody's hand edit), so a step that expected its
    /// own removed key must not read one as "gone" and clobber it.
    private static func equal<Value: Sendable & Equatable>(_ actual: FieldValue<Value>, _ expected: Value?) -> Bool {
        switch (actual, expected) {
        case (.missing, nil): true
        case let (.valid(value), .some(expected)): value == expected
        default: false
        }
    }

    // MARK: Reading

    /// The item's `index.md` as the app reads it, or `nil` when there is no readable, parseable one
    /// there — a folder that is gone, an indexless folder, bytes that are not UTF-8, frontmatter
    /// somebody has just broken.
    private static func index(at folder: URL) -> FrontmatterDocument? {
        let indexURL = folder.appendingPathComponent(BoardLoader.indexFileName)
        guard let data = try? Data(contentsOf: indexURL),
              let text = String(validating: data, as: UTF8.self),
              let document = try? FrontmatterDocument.parse(text)
        else { return nil }
        return document
    }
}
