import Foundation
import Observation

// MARK: - ItemReferenceSet

/// A set of UUIDs over the snapshot plus the container it lives in — **the one shape every
/// piece of transient state that points at items wears**: the selection, drag membership, and the
/// pending cut are three values of this type, not three hand-rolled near-copies
/// (02-architecture.md § Live-reload resilience, "Selection — and every transient state that
/// references items (drag state, pending cut) — is a set of UUIDs over the snapshot").
///
/// **UUIDs, never indices or copies of items.** A reload swaps the whole snapshot as a value, and
/// anything holding positions or item copies would be silently wrong the moment an agent files a
/// card.
///
/// ### One rule, applied in two directions
///
/// `constrained(to:)` *is* the rule — **state may only reference items in the current universe** —
/// and everything else here is that primitive with a universe supplied. Only the universe differs
/// between the two callers:
///
/// - **Reload survival**: the universe is the new snapshot's ids in this set's container, which
///   is what `resolved(against:)` computes before delegating.
/// - **The live search filter**: the universe is the visible ids the predicate produced, so
///   04-interactions.md § Search's "hidden cards leave the selection" needs no second rule — it is
///   this one, with a different universe.
///
/// Both stay **pure value functions**. Deciding *when* to apply them belongs to the caller, and
/// storing the result belongs to `TransientBoardState` — a set that filtered itself would need to
/// know about snapshots, and the whole point of the value-type snapshot is that nothing has to.
public struct ItemReferenceSet: Sendable, Equatable {
    public var ids: Set<ItemID>

    /// Which container the members live in — the board, or `.trash/` (`ItemContainer`).
    ///
    /// A property of the set as a whole rather than of each member, because 04-interactions.md ▸ The
    /// trash keeps exactly one boundary: "a selection never mixes trash cards with board cards".
    /// That is what makes re-resolution across a reload a matching rule rather than a partition.
    public var container: ItemContainer

    public init(ids: Set<ItemID> = [], container: ItemContainer = .board) {
        self.ids = ids
        self.container = container
    }

    /// Nothing referenced, on the board side — the state a board opens in, the state a drag with
    /// nothing in flight is in, and the state `TransientBoardState.clearSelection()` returns to.
    public static let empty = ItemReferenceSet()

    public var isEmpty: Bool { ids.isEmpty }

    /// This set narrowed to `universe`: members that are in it, **container unchanged**.
    ///
    /// The primitive both directions are built from — intersection and nothing else. It is
    /// deliberately ignorant of what a universe *is*: a snapshot's ids in one container
    /// (`resolved(against:)`) and a search predicate's visible ids are the same argument as far as
    /// the rule is concerned, which is what lets one rule be stated once and mean both.
    ///
    /// The container survives even when the membership does not: an emptied set is still a set in a
    /// container, and re-populating it (a fresh click, a new drag) is the caller's business.
    public func constrained(to universe: Set<ItemID>) -> ItemReferenceSet {
        guard !ids.isEmpty else { return self }
        return ItemReferenceSet(ids: ids.intersection(universe), container: container)
    }

    /// This set re-grounded on `snapshot`: the members that are still there, **in the same
    /// container**, and nothing else.
    ///
    /// Two rules, both settled in 02-architecture.md § Live-reload resilience:
    ///
    /// - **Vanished members leave silently.** No substitute is invented, no successor is picked —
    ///   an empty result is a legitimate outcome. (App-mediated deletion is deliberately different:
    ///   ⌫ selects the successor sibling, because that is an act rather than a surprise —
    ///   04-interactions.md ▸ The map. That belongs to the delete command, not here.)
    /// - **A container crossing is a vanish** (resettled 2026-07-28, the materialized trash): "a
    ///   foreign move that trashes a selected board card — or restores a selected trash card —
    ///   ejects it from the selection (and from the pending cut)", keeping 04's container-boundary
    ///   invariant true across reloads so menu validation never sees a mixed selection. Drag
    ///   membership inherits it for free (04 ▸ Drag and drop's emptied-drag rule).
    ///
    /// **Presence is the whole test.** There is no ancestor walk and no effective liveness left to
    /// compute — "the old effective-liveness ancestor walk is retired with the tombstone model"
    /// (02-architecture.md) — because a deleted card's folder has actually moved, and a folder is
    /// either in the container or it is not.
    public func resolved(against snapshot: BoardModel) -> ItemReferenceSet {
        guard !ids.isEmpty else { return self }
        return constrained(to: container.ids(in: snapshot))
    }
}

// MARK: - NewCardPlaceholder

/// The card being created: an inline editor rendered as a pseudo-card **overlaid** on the snapshot,
/// with no disk presence and no UUID until the title commits — 02-architecture.md § Layering's *one
/// named exception* to the one-way flow, and the only thing in `TransientBoardState` that is not a
/// set of ids.
///
/// **Anchored to a lane, never to an item set**, and that asymmetry is the point: an unborn card
/// has no identity to re-resolve, so the only question a reload can ask about it is whether the
/// place it is being born into still exists. `TransientBoardState.resolve(against:)` owns the
/// answer; see its doc comment for the two discard rules.
public struct NewCardPlaceholder: Sendable, Equatable {

    /// How far along the birth is — and therefore what a reload has to check.
    public enum Phase: Sendable, Equatable {
        /// The inline editor is open and the draft is in flight. Nothing has been written; abandoning
        /// (Escape, empty commit, click-away) discards it and disk was never touched
        /// (04-interactions.md ▸ Grammar).
        case editing

        /// The Writer's create ran, so the real card's id is minted — but the watcher has not
        /// round-tripped it yet, and until it does the snapshot has no such card. The overlay stands
        /// in for it across that gap ("the placeholder stays visible until the real card arrives,
        /// then hands off"), which is what keeps the one-way flow from showing a hole where the user
        /// just typed a title.
        case awaitingArrival(ItemID)
    }

    /// The lane the card is being created in — the anchor, and the only item identity a placeholder
    /// has before its title commits.
    public var laneID: ItemID

    /// The card the new one is being born **immediately after**, or `nil` for the lane's bottom.
    ///
    /// It exists because 04-interactions.md's ⌘N target rule is a *position*, not just a lane:
    /// "with a card selected, the new card is created in that card's lane, immediately after it
    /// (paste-anchor consistency)". Every other entry point — Return on a lane, the header button,
    /// a double-click on empty space, ⌘N with nothing selected — appends at the bottom and passes
    /// `nil`.
    ///
    /// **A position, deliberately not a rank.** The `order` is computed at *commit* time against
    /// the snapshot as it is then (`BoardStore.commitPlaceholder`), never frozen when the editor
    /// opened: an agent filing a card into the gap mid-typing must move the new card, not be
    /// overwritten by a stale midpoint. An anchor that has vanished by commit time degrades to the
    /// lane's bottom rather than failing — the lane is the anchor that matters, and the card being
    /// created is the user's, not the vanished neighbour's.
    public var anchorCardID: ItemID?

    /// What the user has typed so far. Lives here and nowhere else: there is no file to hold it.
    public var draftTitle: String

    public var phase: Phase

    public init(
        laneID: ItemID,
        anchorCardID: ItemID? = nil,
        draftTitle: String = "",
        phase: Phase = .editing
    ) {
        self.laneID = laneID
        self.anchorCardID = anchorCardID
        self.draftTitle = draftTitle
        self.phase = phase
    }
}

// MARK: - RenameEditor

/// The **third inline editor** (04-interactions.md ▸ Grammar): a card's or a lane's title being
/// edited in place, on the card face or in the lane header.
///
/// It is `NewCardPlaceholder`'s sibling in shape and its opposite in almost every rule, and the
/// contrast is worth stating once:
///
/// | | `NewCardPlaceholder` | `RenameEditor` |
/// |---|---|---|
/// | References | a *lane* (no identity yet) | an **item**, by UUID |
/// | Click-away | discards — nothing is on disk | **commits** — the item is real |
/// | Empty commit | discards the whole draft | removes the `title` key |
/// | A reload can | drop it, or hand it off | only drop it |
///
/// **Tracked by UUID, which makes a foreign move invisible.** "A foreign *move* mid-rename is
/// invisible — the editor follows the UUID and the commit writes the title wherever the card now
/// lives"; the commit re-derives the folder from the current snapshot, so a card an agent filed
/// into another lane mid-typing is still renamed correctly.
///
/// **The filter never touches it** (04-interactions.md § Search, settled): "an open inline rename
/// survives the filter hiding its card — the editor is a surface the filter doesn't reach; it stays
/// open and focused, commits by UUID wherever the card lives, Escape abandons … and the
/// vanish-discard rule stays reserved for true liveness flips". Two halves, in two places: this
/// editor outlives a reload that stops its card matching, because `resolve(against:)` discards only
/// on a *vanish* and `constrainToSearch(in:)` touches the selection and nothing else; and its
/// *field* outlives it too, because `LaneView.rendered` keeps the renaming card's slot in the
/// masonry for as long as the editor is open.
///
/// There is no phase enum. The placeholder needs one because it outlives its own commit (the
/// overlay stands in for a card that has not arrived yet); a rename has nothing to stand in for —
/// the item is already on screen, and the commit's round trip simply updates it.
public struct RenameEditor: Sendable, Equatable {

    /// The card or lane being renamed. Deliberately untyped as to *kind*: the commit derives the
    /// folder by finding the id in the snapshot, and every rule here — the vanish discard, the
    /// UUID tracking, the empty-commit removal — reads identically for both levels.
    public var targetID: ItemID

    /// What the user has typed. **Seeded from the current title** when the editor opens (an
    /// untitled item seeds empty, since "Untitled" is a rendering, not a value —
    /// 03-board-ui.md § Card face), and thereafter the only place the draft exists.
    public var draftTitle: String

    public init(targetID: ItemID, draftTitle: String = "") {
        self.targetID = targetID
        self.draftTitle = draftTitle
    }
}

// MARK: - TransientBoardState

/// Everything a board's windows share that is **not on disk** — one per `BoardStore`, created with
/// it and dying with it (02-architecture.md § Changes from Kanban, settled m3).
///
/// Its reason for existing is that the store's transient state was becoming a grab-bag. Every member
/// below is filed by **how a reload treats it**, and there are exactly three kinds plus a remainder:
///
/// 1. **Item-referencing sets** — `selection`, `dragMembers`, `pendingCut`. One shape
///    (`ItemReferenceSet`) and one constraint rule, *members must exist in the current universe*.
///    `resolve(against:)` applies it to each of them **independently**: a card vanishing from the
///    selection has no business disturbing a drag in flight or a pending cut, and independence is
///    the only way that stays true without three orders of operations to reason about.
/// 2. **Derived state, stored as its inputs only** — `searchQuery` is kept; its *result set* is
///    deliberately absent. The filter is a live predicate re-run against each new snapshot
///    (04-interactions.md § Search), so a card an agent files mid-search appears the moment the
///    reload lands and a card edited to no longer match animates out. A stored result set would be
///    a second, staler answer to a question the snapshot can always answer, and would need a
///    re-resolution rule of its own — which is exactly the accretion this type exists to stop.
/// 3. **The inline editors** — `newCardPlaceholder`, anchored to a lane rather than to items,
///    discarded when the lane it is anchored to goes away and handed off when the real card
///    arrives; and `renameEditor`, anchored to an *item* and discarded when that item vanishes.
///    Two editors, one at a time: they share the app's single keyboard focus, so beginning either
///    ends the other.
///
/// The remainder is plain per-open values: `isTrashVisible` is hidden on every open and **never
/// persisted** — visiting the trash is an errand, not a layout choice. It needs no reset logic
/// because this object is built fresh with its store; closing the board is the reset.
/// `lastActiveLaneID` is per-open in the same sense — "in this window session" is exactly the
/// scope of a container built with its store — but it *does* reference an item, so `resolve` has
/// something to say about it.
///
/// `@MainActor` because it is read by SwiftUI on the main actor and mutated by gestures there;
/// `@Observable` so the board window and its card windows re-render off the same truth.
@MainActor
@Observable
public final class TransientBoardState {

    // MARK: Item-referencing sets

    /// What the user has selected, re-resolved against every snapshot the store applies.
    ///
    /// `private(set)` with mutators below because the selection is the one set with a *grammar*
    /// coming (extend, range, successor-on-delete — 04-interactions.md ▸ Grammar), and every one of
    /// those rules will want a single funnel. Its siblings are plain `var`s: a drag and a cut are
    /// set wholesale by the gesture that owns them.
    public private(set) var selection: ItemReferenceSet = .empty

    /// **Where a ⇧-click ranges from** — the item the last plain or ⌘ click named
    /// (04-interactions.md § Selection, "⇧-click range-extends").
    ///
    /// A *memory of a gesture*, like `lastActiveLaneID` and for the same reason: it is not
    /// derivable from the selection. A range replaces the whole set and deliberately leaves the
    /// anchor put, so successive ⇧-clicks sweep out from one origin instead of walking it along —
    /// which means nothing in the set marks it.
    ///
    /// **A marquee and every wholesale selection pass no anchor deliberately.** There is no click
    /// behind them to range from, so a ⇧-click afterwards behaves as a plain click — the same
    /// degrade `SelectionGrammar` gives a vanished anchor, reached honestly rather than by inventing
    /// an origin the user never named.
    ///
    /// It lives on the selection's side by construction, so `resolve(against:)` re-grounds it with
    /// the same rule every other item reference gets.
    public private(set) var selectionAnchor: ItemID?

    /// **Where the next arrow steps from** — the navigation cursor, AppKit's "lead": the item the
    /// last click or arrow named (04-interactions.md ▸ Grammar's spatial navigation).
    ///
    /// **Distinct from the anchor, and the difference is the whole reason both exist.** A ⇧-gesture
    /// leaves the anchor exactly where it was — that is what makes successive extensions sweep out
    /// from one origin — while the *head* walks to whatever was just reached, because the next
    /// ⇧-arrow has to continue from there rather than from the origin. A plain click or arrow moves
    /// both; a ⇧-click or ⇧-arrow moves only this.
    ///
    /// A memory of a gesture like the anchor, on the selection's side by construction, and re-grounded
    /// by `resolve(against:)` under the same universe rule.
    public private(set) var selectionHead: ItemID?

    /// **What position in its lane a run of ←/→ is holding on to** — the sticky ordinal (ruled
    /// 2026-08-09), 1-based over the cards the board is *showing*, `nil` when no lateral run is in
    /// flight.
    ///
    /// The third memory of a gesture, and the one that exists because a *screen* cannot remember it:
    /// stepping from a 10-card lane's 8th card into a 3-card lane clamps to its 3rd, and every rule
    /// that reads drawn rectangles — `NavigationMath.nearest` included — then has only "3rd card"
    /// to step back out with. The number the user is walking at survives here instead, unclamped, so
    /// the hop back lands on the 8th again (`NavigationMath.lateralHop`).
    ///
    /// **`nil` is a reset, and every existing caller gets one for free**: `select` takes it as a
    /// defaulted parameter and stores it verbatim, so a click, a marquee, a ↑/↓ step, an ⌥-jump, a
    /// ⌫ successor, a lane-domain arrow and the reload's focus recovery all end the run without any
    /// of them naming it. Only the lateral hop passes a value. Reading `nil` on the next hop is not a
    /// missing answer either — it means "start a run here", which captures the cursor's *actual*
    /// position, which is the ruling's "resets to the actual new position" arrived at lazily.
    ///
    /// **An ordinal rather than a card**, deliberately: the thing being preserved is a position in a
    /// list, and the lists on either side of a hop are different lanes with different lengths. It
    /// therefore references no item, which is why `resolve(against:)` has only one thing to say about
    /// it — see there.
    public private(set) var lateralOrdinal: Int?

    /// The items a drag is carrying — **empty when no drag is in flight**, which is what "no drag"
    /// means here rather than a separate flag.
    ///
    /// Reload-resolved like any set: partial vanishing drops the survivors, and when the last
    /// member goes the drag has emptied itself and cancels (04-interactions.md ▸ Drag and drop,
    /// "an emptied drag cancels itself"). Deciding what an emptied drag *does* is the drag
    /// controller's job (m5); losing the members is this rule's.
    public var dragMembers: ItemReferenceSet = .empty

    /// The ⌘X staging set: cut items dim in place until a paste moves them (04-interactions.md ▸
    /// Clipboard, "Cut is Finder-style deferred").
    ///
    /// The pasteboard and the staged folder snapshots are not here — they are app-wide, outlive this
    /// store, and are 04's own story. This is only the *in-board* half: which of this board's items
    /// are showing as cut. "Deletion voids per item" is the reload rule above, applied here for
    /// free.
    public var pendingCut: ItemReferenceSet = .empty

    // MARK: Derived state, stored as its inputs

    /// The live search field's text (04-interactions.md § Search). Empty means no search is active.
    ///
    /// **The result set is not stored** — see this type's doc comment, kind 2. The predicate runs
    /// against whatever snapshot is current, so the filter is never stale, and the selection is kept
    /// honest against it by `ItemReferenceSet.constrained(to:)` with the visible ids as the universe
    /// — the same rule a reload uses, which is why "hidden cards leave the selection" needs no code
    /// of its own (`constrainToSearch(in:)`).
    ///
    /// **Written through `BoardStore.searchQuery`, not here**, on every path that *narrows* it: the
    /// store is what has a snapshot, and narrowing without constraining would leave a selection
    /// pointing at cards nobody can see. Widening — `beginPlaceholder`'s creation clear, and the
    /// clear Escape performs — is safe from anywhere, because a bigger universe invalidates nothing.
    public var searchQuery: String = ""

    // MARK: The inline editors

    /// The new-card placeholder, or `nil` when no card is being created. See `NewCardPlaceholder`
    /// for what it is and `resolve(against:)` for what a reload does to it.
    ///
    /// `private(set)`: its four legal transitions are the lifecycle methods below, and an unborn
    /// card is exactly the kind of state that rots if anyone may assign it.
    public private(set) var newCardPlaceholder: NewCardPlaceholder?

    /// The inline rename in flight, or `nil` when no title is being edited. See `RenameEditor`.
    ///
    /// `private(set)` for the placeholder's reason, plus one of its own: the *commit* is a write
    /// that only `BoardStore` can perform, so an editor assignable from anywhere could be cleared
    /// out from under a commit that was about to read its draft.
    public private(set) var renameEditor: RenameEditor?

    /// The open Style… popover's target, or `nil` when no popover is open (03-board-ui.md § Styling
    /// ▸ Controls). See `StyleEditorSession` for the lifecycle it implements and why it lives here
    /// rather than in a view.
    ///
    /// **Not an inline editor**, deliberately: it is not a title field, it does not hold the text
    /// domain's keyboard, and `isEditingInline` must stay the answer to "may board commands run" —
    /// Style… itself is *disabled* while an inline editor is open, so the two never coexist anyway.
    ///
    /// `private(set)` for `renameEditor`'s reason: its two transitions are the methods below, and a
    /// session assignable from anywhere could be re-aimed behind the reload rule's back.
    public private(set) var styleEditor: StyleEditorSession?

    /// The open **Labels ▸ More…** dialog's card, or `nil` when none is open (the card context menu's
    /// `labels` submenu; `FrontmatterKeys.labels`, activated 2026-08-09). `styleEditor`'s neighbour in
    /// every respect — same reason it lives here rather than in a view, same `private(set)`, same
    /// re-grounding below — narrowed to one card because every row in that dialog is a checkmark about
    /// one card (`LabelEditorSession`).
    ///
    /// **Not an inline editor either**, `styleEditor`'s own note: the dialog's create field holds text,
    /// but `isEditingInline` is the answer to "may board commands run" and this surface is opened *by*
    /// one of them.
    public private(set) var labelEditor: LabelEditorSession?

    /// Whether a title editor holds focus — 04-interactions.md's **focused-editor rule** as one
    /// boolean: "while an inline title editor — rename or the new-card placeholder — is focused,
    /// board-scoped menu commands (Delete, New Card, Paste, Move, Style, …) disable via menu
    /// validation".
    ///
    /// Every board-mutating menu item validates against this, so the rule is stated once rather
    /// than re-derived per item. The one carve-out the design names — Open Card ⌘↩, which stays
    /// enabled to commit the edit and open the window — is the item's business, not this flag's.
    public var isEditingInline: Bool {
        newCardPlaceholder != nil || renameEditor != nil
    }

    // MARK: Per-open values

    /// The lane that most recently held selection or a creation **in this window session** —
    /// 04-interactions.md's ⌘N target rule's fallback when nothing (or a trash selection, which
    /// never anchors creation) is selected, before the last resort of the first lane.
    ///
    /// It is a *memory of a gesture*, not derived state: with an empty selection there is nothing
    /// in the snapshot that could reconstruct which lane the user was last working in, which is
    /// precisely why the rule exists — a ⌘N after an Escape should file the card where the user
    /// has been, not at the far left of the board.
    ///
    /// `resolve(against:)` clears it when the lane vanishes, because a target that renders nowhere
    /// is no target at all; `NewCardTarget` then falls through to the first lane.
    public private(set) var lastActiveLaneID: ItemID?

    /// Whether the trash column is showing (03-board-ui.md ▸ Trash).
    ///
    /// **Hidden on every open, never persisted**: visiting the trash is an errand, not a layout
    /// choice, so it does not belong in the board registry beside window frames
    /// (02-architecture.md § Per-board app state). Nothing resets it — a fresh store means a fresh
    /// container means `false`.
    public var isTrashVisible: Bool = false

    public init() {}

    // MARK: - Selection

    /// Replaces the selection, and sets the anchor a subsequent ⇧-click ranges from and the head a
    /// subsequent arrow steps from.
    ///
    /// The grammar itself is `SelectionGrammar`'s — pure, testable, and the one funnel every click
    /// surface goes through (`BoardStore.click`). This is the storage half, and its only rule of its
    /// own is the **anchor default**, which the head shares: `nil` with a sole member takes that
    /// member, `nil` with any other count takes nothing. That makes the callers that pass nothing
    /// behave exactly as they should — a one-item selection made by any route is a legitimate range
    /// origin *and* a legitimate place to arrow from.
    ///
    /// **The default is opt-out, because one caller genuinely names no gesture.** A Select All over a
    /// one-card board is still a selection the user pointed at, but a rubber band is not: it names no
    /// click to range from and no item to arrow from *whatever* it happens to enclose, and passing
    /// `nil` cannot say so — `nil` is what asks for the default. So `defaultsSoleMember: false` takes
    /// `anchor` and `head` verbatim, `nil` included, and a band that sweeps exactly one card leaves
    /// both cursors empty instead of quietly acquiring them (`MarqueeControl.gesture`; a
    /// `selectionHead` set mid-band would additionally fire `LaneView.cardStack`'s scroll-to under the
    /// user's own drag).
    ///
    /// Deliberately **not** filtered against the snapshot: a caller selects what it is rendering, and
    /// `resolve(against:)` on the next reload is what keeps the set honest over time.
    ///
    /// - Parameter defaultsSoleMember: whether a `nil` anchor or head falls back to a sole member.
    /// - Parameter lateralOrdinal: the sticky ordinal a run of ←/→ is carrying, or `nil` — which is
    ///   what **every** caller but the lateral hop passes, and is why "a non-lateral selection change
    ///   resets it" needs no enumeration of the gestures anywhere. See `lateralOrdinal`.
    public func select(
        _ ids: Set<ItemID>,
        in container: ItemContainer,
        anchor: ItemID? = nil,
        head: ItemID? = nil,
        defaultsSoleMember: Bool = true,
        lateralOrdinal: Int? = nil
    ) {
        selection = ItemReferenceSet(ids: ids, container: container)
        let sole = defaultsSoleMember && ids.count == 1 ? ids.first : nil
        selectionAnchor = anchor ?? sole
        selectionHead = head ?? sole
        self.lateralOrdinal = lateralOrdinal
    }

    /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar). The anchor and
    /// the head go with it: an empty selection has no origin to range from and no cursor to step
    /// from — which is exactly the state the arrows' seed rule answers. The sticky ordinal goes for
    /// the same reason: there is no run left to be in the middle of.
    public func clearSelection() {
        selection = .empty
        selectionAnchor = nil
        selectionHead = nil
        lateralOrdinal = nil
    }

    /// Records that `laneID` is where the user is working — a lane selected, or created into.
    ///
    /// **`nil` is a no-op, not a clear.** "Last-active" is a high-water mark: clearing the
    /// selection does not un-happen the lane the user was just in, and 04-interactions.md's rule
    /// leans on exactly that (⌘N *with nothing selected* is the case the memory serves). The only
    /// thing that clears it is the lane going away, which `resolve(against:)` owns.
    public func noteActiveLane(_ laneID: ItemID?) {
        guard let laneID else { return }
        lastActiveLaneID = laneID
    }

    // MARK: - Creation's carve-out

    /// **A user-initiated creation clears the query** — 04-interactions.md § Search's one exception
    /// to the pure predicate, and the single place it is stated.
    ///
    /// > creating a card clears the search — creation's carve-out exists because a brand-new card
    /// > must not be born invisible, and it is **stated by mechanism, not by gesture** (settled):
    /// > *any* user-initiated creation on the board clears the query — ⌘N, Return-creation, the
    /// > header button, empty-space double-click, paste, and Finder file drops alike — while
    /// > foreign/agent-filed cards keep riding the live filter.
    ///
    /// So this is a *seam*, not a gesture's line: every path that mints an item because the user
    /// asked for one calls it, and there are exactly three of them —
    ///
    /// - `beginPlaceholder(inLane:after:)` below, which is itself the funnel for the four inline
    ///   creation gestures (⌘N, Return on a lane, the header button, a double-click on empty space);
    /// - `ClipboardStore.perform` — ⌘V, cards and lanes alike, at the moment the items actually
    ///   land (a paste the pasteboard went stale under lands nothing and so clears nothing);
    /// - `BoardStore.createCards(fromFiles:inLane:at:)` — the Finder file drop's *create* half. Its
    ///   attach half (`importAttachments`) deliberately does not call this: dropping files on a card
    ///   creates nothing, so there is no card to be born invisible.
    ///
    /// **What is deliberately not here** is the other half of the same sentence: an item this board
    /// receives without the user asking *it* for one keeps riding the filter. A cross-board drag
    /// arrival is the near miss — it is a transfer whose destination-side clear 04 does not state,
    /// and the enumerated mechanisms above are the ones it does — and an agent filing a card is the
    /// far one (02-architecture.md's derived-result rule). **New Lane is not here either**, for the
    /// carve-out's own reason rather than in spite of it: "lanes are never filtered out", so a lane
    /// cannot be born invisible and has nothing to be rescued from — ⇧⌘N under a query adds a lane
    /// showing a `0` badge, which is the filter behaving exactly as designed.
    ///
    /// Widening the universe invalidates nothing, so unlike a *narrowing* write this needs no
    /// snapshot and no `constrainToSearch(in:)` behind it — which is why the rule can live on this
    /// type at all rather than on the store (see `searchQuery`).
    public func noteUserCreation() {
        searchQuery = ""
    }

    // MARK: - The placeholder's lifecycle

    /// Opens the inline editor for a new card in `laneID`, replacing any editor already open and
    /// marking the lane active.
    ///
    /// Replacing rather than refusing: two inline editors can never be open at once (one focus),
    /// so a second begin is the first one being abandoned — 04-interactions.md's click-away
    /// discard, arriving as a new creation instead of a click (02-architecture.md § Layering
    /// states it outright: "Starting a new creation while a placeholder is open is a click-away
    /// for the draft"). A rename in flight is dropped for the same reason; a rename's click-away
    /// would ordinarily *commit*, but that rule is about focus leaving for the board, and here the
    /// focus is being taken by another editor before the user has said they are done.
    ///
    /// **Creation clears the search, and this is the funnel for the inline gestures**
    /// (04-interactions.md § Search): "a brand-new card must not be born invisible". ⌘N, Return on a
    /// lane, the lane header's button and a double-click on empty space all arrive here, so the four
    /// of them state the carve-out once — by calling the rule's one home, `noteUserCreation()`,
    /// which the paste and Finder-file-drop paths call too.
    ///
    /// **Rename deliberately gets no such line** (04, settled): "the filter stays a pure predicate
    /// with one exception, not two". A rename committed under an active search re-runs the
    /// predicate like any other edit, and a title that stops matching animates its card out and
    /// drops it from the selection — which falls out of `BoardStore.commitRename`'s ordinary write
    /// and the reload's `constrainToSearch(in:)`, with nothing here to arrange it.
    ///
    /// - Parameter anchorCardID: the card the new one is born immediately after (04's ⌘N target
    ///   rule), or `nil` for the lane's bottom — which is what Return, the header button, and a
    ///   double-click on empty space all pass.
    public func beginPlaceholder(inLane laneID: ItemID, after anchorCardID: ItemID? = nil) {
        noteUserCreation()
        renameEditor = nil
        newCardPlaceholder = NewCardPlaceholder(laneID: laneID, anchorCardID: anchorCardID)
        noteActiveLane(laneID)
    }

    /// Records what the user has typed. A no-op with no placeholder open — the draft has nowhere to
    /// live, and inventing a placeholder to hold it would put an editor on screen that nobody asked
    /// for.
    public func updateDraft(_ title: String) {
        newCardPlaceholder?.draftTitle = title
    }

    /// Marks the placeholder as waiting for the card the Writer just created.
    ///
    /// **The Writer call is not made here.** This type stores no URLs and performs no I/O; the create
    /// runs through `BoardStore.performWrite` at the UI's call site (m5), which is also the only
    /// place that can decide what a *failed* create should do with the editor. All this records is
    /// the id to watch for, so the overlay knows when its job is done.
    ///
    /// A no-op with no placeholder open: nothing is awaiting anything.
    public func commitPlaceholder(expecting id: ItemID) {
        newCardPlaceholder?.phase = .awaitingArrival(id)
    }

    /// Abandons the placeholder — Escape, an empty commit, a click-away (04-interactions.md ▸
    /// Grammar). Disk was never touched, so there is nothing to undo.
    public func discardPlaceholder() {
        newCardPlaceholder = nil
    }

    // MARK: - The rename editor's lifecycle

    /// Opens the inline rename of `targetID`, seeded with `currentTitle`.
    ///
    /// The seed is the caller's because this type holds no snapshot: the two entry points (Return
    /// on a sole selected card, Board ▸ Rename) both have the item in hand already. `nil` seeds an
    /// empty field — an untitled item has no title to edit, and "Untitled" is a rendering that
    /// must never be typed into the file (03-board-ui.md § Card face).
    ///
    /// Replaces whatever editor was open, for `beginPlaceholder`'s reason: one focus, one editor.
    public func beginRename(of targetID: ItemID, currentTitle: String?) {
        newCardPlaceholder = nil
        renameEditor = RenameEditor(targetID: targetID, draftTitle: currentTitle ?? "")
    }

    /// Records what the user has typed. A no-op with no editor open, like `updateDraft`.
    public func updateRenameDraft(_ title: String) {
        renameEditor?.draftTitle = title
    }

    /// Closes the rename editor without writing — **Escape's abandon**, and also how
    /// `BoardStore.commitRename` retires the editor once its write has been issued (or refused).
    ///
    /// There is no `commitRename` here for the same reason there is no create here: this type
    /// stores no URLs and performs no I/O. The keystrokes are simply dropped; on the abandon path
    /// disk was never touched, and on the commit path disk has already been touched by the time
    /// this runs.
    public func discardRename() {
        renameEditor = nil
    }

    // MARK: - The style editor's lifecycle

    /// Opens the Style… popover on `target` — the menu item's call and both context menus'
    /// (03-board-ui.md § Styling ▸ Controls).
    ///
    /// Replacing any session already open, for the inline editors' reason turned inside out: there is
    /// one popover, so a second Style… is the first one being re-aimed by a fresh gesture. Unlike
    /// `beginRename`/`beginPlaceholder` it does **not** clear the inline editors — it cannot coexist
    /// with one (Style… disables while an editor is focused), so clearing them here would be a rule
    /// about a state that menu validation already rules out.
    public func beginStyleEditor(for target: StyleTarget) {
        styleEditor = StyleEditorSession(target: target)
    }

    /// Closes the popover — the user dismissing it, and the anchor's response to a session the
    /// reload rule emptied.
    public func discardStyleEditor() {
        styleEditor = nil
    }

    // MARK: - The labels dialog's lifecycle

    /// Opens **Labels ▸ More…** on one card — the card context menu's row, and its only caller.
    ///
    /// Replacing any session already open, `beginStyleEditor`'s rule and its reason: there is one
    /// dialog, so a second More… is the first one being re-aimed by a fresh gesture.
    public func beginLabelEditor(forCard cardID: ItemID) {
        labelEditor = LabelEditorSession(cardID: cardID)
    }

    /// Closes it — the user dismissing it, and the anchor's response to a session the reload rule
    /// emptied.
    public func discardLabelEditor() {
        labelEditor = nil
    }

    // MARK: - Reload

    /// The one reload hook: re-grounds every piece of this container on a freshly applied snapshot.
    ///
    /// Called by `BoardStore` on each *successful* reload and nowhere else — a failed reload leaves
    /// the snapshot alone, and state over a snapshot that did not change has nothing to re-resolve
    /// against.
    ///
    /// **Every item-referencing set is resolved independently.** They are re-grounded against the
    /// same snapshot but never against each other: a card leaving the selection must not disturb a
    /// drag in flight or a pending cut that also held it, and each set carries its own container.
    /// Independence is what makes that a property of the code rather than of the order the lines
    /// happen to be in.
    ///
    /// **The placeholder has its own two rules**, because it references a lane rather than items:
    ///
    /// - **Discarded when its anchor lane is gone** — absent from the snapshot. "If the
    ///   placeholder's lane vanished in the reload, it is discarded" (02-architecture.md); a lane
    ///   delete is physical now, so gone is the only way a lane goes.
    /// - **Discarded as a hand-off** when it is `.awaitingArrival(id)` and `id`'s card is in the
    ///   snapshot. The real card arrived; the overlay's whole job was covering the gap between the
    ///   Writer's create and the watcher's round trip, and holding it a moment longer would draw the
    ///   card twice. The card is looked for anywhere in the snapshot rather than only under the
    ///   anchor lane — it arrived, and where it landed is the snapshot's business.
    ///
    /// Otherwise the placeholder survives untouched: reloads swap the snapshot *underneath* the
    /// overlay, exactly as they do underneath the selection.
    ///
    /// **The rename editor has one rule, and it is the vanish rule** (04-interactions.md ▸
    /// Grammar, "Inline rename tracks its target by UUID, and vanishing discards it"): a target
    /// that is trashed, deleted, or gone discards the editor and its keystrokes silently —
    /// "entering the trash is a vanish from the board; nothing is ever written into a vanished
    /// folder". A foreign *move between lanes* is deliberately not a vanish — the editor follows
    /// the UUID and the commit writes wherever the card now lives — which falls out for free from
    /// matching on identity within the board container.
    ///
    /// **`lastActiveLaneID` is cleared when its lane goes**, for the reason 02-architecture.md
    /// gives every item-referencing piece of transient state: nothing may reference an item the
    /// current universe does not have. It is not an `ItemReferenceSet` only because it is one
    /// optional rather than a set on a side — the rule it obeys is the same one.
    ///
    /// **`selectionAnchor` obeys it too**, in the *selection's* container: a range origin that
    /// vanished or crossed containers is gone, and the next ⇧-click acts as a plain click rather
    /// than ranging from somewhere that renders nowhere. It deliberately does **not** have to stay
    /// *in* the selection — a ⌘-click that toggles the anchor's neighbour out leaves the anchor
    /// selected and a range from it is still exactly what the user asked for.
    ///
    /// **The style editor tracks its target set live** (03-board-ui.md § Styling ▸ Controls,
    /// settled): a member that vanishes or crosses containers leaves the set — so the editor's
    /// mixed-state display recomputes off the survivors — and a set emptied by a foreign reload
    /// clears the session, which is how "the popover dismisses when it empties" reaches the screen.
    /// It never becomes a board session on the way; `StyleEditorSession.resolved(against:)` owns
    /// both halves.
    ///
    /// **`searchQuery` is re-applied rather than re-resolved.** It references no item, so no
    /// snapshot can invalidate it — but its *results* change with every snapshot, and a reload
    /// landing under an active query can hide a selected card as surely as a query change can (an
    /// agent editing a title out of the match is the case). So `constrainToSearch(in:)` runs last,
    /// on the freshly resolved sets, and the vanish rule and the filter rule compose in the one
    /// order that makes sense: gone first, then hidden. `isTrashVisible` is the only member with
    /// nothing to say here at all.
    ///
    /// - Parameter commentMatches: the transient comment index' current answer
    ///   (`CommentSearchIndex.matchingCards`), threaded straight through to the constraint below.
    ///   Defaulted, because it is the store's fact rather than this container's: everything else here
    ///   is a rule about *this* state, and a caller with no index — every test of the resolution rules
    ///   — means "no comment matches", which is what an empty set says.
    public func resolve(against snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
        selection = selection.resolved(against: snapshot)
        dragMembers = dragMembers.resolved(against: snapshot)
        pendingCut = pendingCut.resolved(against: snapshot)
        newCardPlaceholder = resolvedPlaceholder(against: snapshot)
        styleEditor = styleEditor?.resolved(against: snapshot)
        labelEditor = labelEditor?.resolved(against: snapshot)

        // One universe computed once and asked three questions — the rename target's container, the
        // last-active lane's, and (via the placeholder above, which asks its own way) the anchor's.
        let board = ItemContainer.board.ids(in: snapshot)
        if let editor = renameEditor, !board.contains(editor.targetID) {
            renameEditor = nil
        }
        if let lane = lastActiveLaneID, !board.contains(lane) {
            lastActiveLaneID = nil
        }
        if selectionAnchor != nil || selectionHead != nil {
            // The selection's container, because that is where both cursors live by construction —
            // every route that sets either sets the selection to the same container in the same
            // call. A vanished or container-crossed cursor is gone, which is the rule every item
            // reference here gets. The head then re-derives from the selection's last member on the
            // next arrow, which is the same fallback an anchorless ⇧-arrow already uses.
            let universe = selection.container == .board ? board : selection.container.ids(in: snapshot)
            if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
            if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
        }
        // **The sticky ordinal never outlives the head it was counted from** — the one thing a reload
        // has to say about a value that references no item. A run of ←/→ whose cursor vanished has
        // nothing left to be a run *of*, and the next arrow re-derives its head from the selection's
        // last member, which is a position the run never named. A reload that leaves the head standing
        // deliberately leaves the run standing too: an agent filing a card mid-walk is not the user
        // changing their mind.
        if selectionHead == nil { lateralOrdinal = nil }

        constrainToSearch(in: snapshot, commentMatches: commentMatches)
    }

    /// **Hidden cards leave the selection** (04-interactions.md § Search) — the constraint rule with
    /// the *filter's* universe supplied, which is the second of the two directions
    /// `ItemReferenceSet.constrained(to:)`'s doc comment names.
    ///
    /// Called on exactly two occasions, and they are the two ways the visible universe can narrow:
    /// when the **query changes** (`BoardStore.searchQuery`'s setter) and when a **reload lands
    /// under an active query** (`resolve(against:)` above, whose last line this is). Both hand it
    /// the current snapshot, because the predicate has nothing else to run against.
    ///
    /// **A no-op with no search running**, deliberately: with the filter off the visible universe is
    /// the whole board, so constraining to it could only ever be the identity — and stating that as
    /// an early return rather than letting it fall out keeps the reload path free of a board-sized
    /// set computation nobody needs.
    ///
    /// The anchor and the head obey the same universe rule the reload gives them, for the same
    /// reason: a range origin or a navigation cursor sitting on a card the filter hid would range or
    /// step from somewhere the user cannot see. Neither has to stay *in* the selection — that
    /// asymmetry is `resolve`'s and survives here untouched.
    ///
    /// **The drag, the pending cut and the rename editor are deliberately left alone.** 04 hides
    /// cards and says one thing about the consequence — that they leave the *selection*. A cut is
    /// staged content waiting for a paste that may well happen after the search clears, and a drag
    /// under a live filter is a gesture in flight, not a set the filter has any claim on. The
    /// editor's absence is the settled ruling in person: "an open inline rename survives the filter
    /// hiding its card … the vanish-discard rule stays reserved for true liveness flips"
    /// (`RenameEditor`) — read for the materialized trash, true container crossings — so a foreign
    /// edit that stops the renaming card matching drops it from the selection here and leaves the
    /// keystrokes exactly where the user left them.
    /// **A third occasion joined the two** with the comment index (04 ▸ Search, re-ruled 2026-07-29):
    /// a landed sweep can *widen* the visible universe (a card matching only through its comments
    /// appears) and, on a re-sweep, narrow it again — so `CommentSearchIndex.onRefine` calls this too.
    /// Widening constrains nothing, which is why the seam is worth having anyway: the narrowing half is
    /// the one that would otherwise leave a selection on a card nobody can see.
    public func constrainToSearch(in snapshot: BoardModel, commentMatches: Set<ItemID> = []) {
        let filter = SearchFilter(query: searchQuery, commentMatches: commentMatches)
        guard filter.isActive else { return }

        let universe = filter.visibleIDs(in: snapshot, container: selection.container)
        selection = selection.constrained(to: universe)
        if let anchor = selectionAnchor, !universe.contains(anchor) { selectionAnchor = nil }
        if let head = selectionHead, !universe.contains(head) { selectionHead = nil }
        // A hidden cursor ends the lateral run, for `resolve`'s reason: the ordinal counts what the
        // user can see, and a run stepping from a card the filter took away counts from nowhere.
        if selectionHead == nil { lateralOrdinal = nil }
    }

    /// The placeholder's two discard rules, as a pure function of the placeholder and the snapshot.
    private func resolvedPlaceholder(against snapshot: BoardModel) -> NewCardPlaceholder? {
        guard let placeholder = newCardPlaceholder else { return nil }

        guard snapshot.lanes.contains(where: { $0.id == placeholder.laneID }) else { return nil }

        if case let .awaitingArrival(id) = placeholder.phase,
           snapshot.lanes.contains(where: { $0.cards.contains { $0.id == id } }) {
            return nil
        }

        return placeholder
    }
}
