import Foundation
import Observation
import os

// MARK: - Vocabulary

/// Why a board is refusing writes — the read-only lock's cause, and now the whole of the
/// vocabulary 02-architecture.md names.
///
/// The three cases share one *scope* (§ "The lock's scope") — every mutating command disabled
/// across every window sharing the store, drops refused, ⌘-drag moves degraded to copies, editor
/// buffers kept but their debounced saves suspended — and differ only in cause and in **what
/// clears them**, which is the one thing this enum's cases are actually asked about (see
/// `BoardStore.land(_:generation:origin:)`). The banner turns a case of this into user-facing
/// phrasing (§ The banner surface); the store only owns the truth of it.
public enum ReadOnlyLockReason: Sendable, Equatable {
    /// A reload that followed a bracketed wholesale operation failed, so the last-good snapshot on
    /// screen may describe a tree that no longer exists — after a branch switch, a different branch
    /// entirely. Writes derived from it would land nonsense, so every write is refused until a
    /// reload succeeds (02-architecture.md § Live-reload resilience, "A failed reload after a
    /// bracketed operation locks the board read-only").
    ///
    /// **Clears on the next successful reload, whatever its origin** — typically once the offending
    /// file is fixed.
    case bracketedReloadFailed

    /// The board's root is gone and its bookmark re-resolution found nothing: the volume unmounted,
    /// or the folder was deleted in Finder while the board was open (02-architecture.md §
    /// Write-failure surfacing, "A vanished board root locks the board read-only"). Every write
    /// would land nowhere, so the last-good snapshot stays on screen, read-only.
    ///
    /// **Clears on the next successful reload, whatever its origin**: a reload can only succeed if
    /// the root is back, so success *is* the return signal. Pending dirty buffers then save
    /// normally.
    case vanishedRoot

    /// The board opened somewhere it cannot be written: a read-only volume (a DMG, a snapshot, a
    /// read-only share) or a permission-denied folder (02-architecture.md § Write-failure
    /// surfacing, "An unwritable board location enters the read-only lock at open"). Failing
    /// loudly, specifically, *once* beats letting every gesture fail one at a time.
    ///
    /// **Clears only on a successful *reconciling* reload whose writability re-probe passes** —
    /// unlike its two siblings, whose cause a successful reload disproves by itself. A board on a
    /// read-only DMG reloads perfectly all day long; only the probe (§ "Writability re-probes on
    /// every reconciling reload" — wake, activation) can tell that the permission or the mount
    /// actually changed.
    case unwritableLocation
}

/// The refusal `BoardStore.performWrite` throws when the board is locked read-only.
///
/// **Deliberately temporary, and deliberately not a `BoardWriteError`.** The lock is a *store*
/// condition, not a filesystem outcome: nothing was attempted, no path failed, and folding it into
/// `BoardWriteError.io(message:)` would misrepresent a policy refusal as an I/O error in the one
/// place — the banner — where the distinction is the whole point. Widening `BoardWriteError` to
/// carry a refusal case is the banner card's job (02-architecture.md § Write-failure surfacing:
/// "The operation is a closed enum, not a string"), and it will unify this vocabulary with the
/// Writer's. Until then this thin error keeps `performWrite`'s honesty at the cost of an untyped
/// `throws` on its signature.
public enum BoardStoreWriteRefusal: Error, Sendable, Equatable, CustomStringConvertible {
    case readOnlyLocked(ReadOnlyLockReason)

    public var description: String {
        switch self {
        case let .readOnlyLocked(reason):
            "the board is read-only (\(reason))"
        }
    }
}

/// Which side of the live/tombstoned boundary something sits on.
///
/// A selection is **homogeneous by liveness** (04-interactions.md § The trash): it never mixes live
/// and tombstoned items, so the side is a property of the selection as a whole rather than of each
/// member — which is exactly what makes re-resolution across a reload a matching rule rather than a
/// partition.
public enum Liveness: Sendable, Equatable {
    case live
    case trashed

    /// The side an item's own tombstone flag puts it on. `Lane.isDeleted`/`Card.isDeleted` are
    /// presence-of-the-key, not validity, so a malformed `deleted:` still reads as trashed — see
    /// their doc comments in `BoardModel.swift`.
    init(isDeleted: Bool) {
        self = isDeleted ? .trashed : .live
    }
}

// MARK: - Selection

/// The board's selection: a set of UUIDs over the snapshot, plus the liveness side it lives on.
///
/// **UUIDs, never indices or copies of items** (02-architecture.md § Live-reload resilience,
/// "Selection survives reloads by UUID"): 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. Re-resolution against the new snapshot is `resolved(against:)`, and it is a *pure
/// function* on purpose — the transient-state container (02-architecture.md § Changes from Kanban)
/// will absorb this type and apply the same rule to drag membership and the pending cut, so the
/// rule must be reusable rather than buried in the store's reload path.
public struct Selection: Sendable, Equatable {
    public var ids: Set<ItemID>
    public var liveness: Liveness

    public init(ids: Set<ItemID> = [], liveness: Liveness = .live) {
        self.ids = ids
        self.liveness = liveness
    }

    /// Nothing selected, on the live side — the state a board opens in and the state
    /// `BoardStore.clearSelection()` returns to.
    public static let empty = Selection()

    public var isEmpty: Bool { ids.isEmpty }

    /// This selection re-grounded on `snapshot`: the members that are still there, **on the same
    /// liveness side**, 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 liveness flip is a vanish.** A foreign edit that tombstones a selected live card — or
    ///   restores a selected tombstoned one — ejects it, keeping 04-interactions.md's
    ///   homogeneous-by-liveness invariant true across reloads so menu validation never sees a
    ///   mixed selection.
    ///
    /// The liveness that is matched is **effective — ancestor-walked** (settled): a card counts as
    /// trashed if its own flag *or its lane's* says so. Tombstoning a lane therefore ejects its
    /// cards from a live selection even though their own flags never changed — the card renders
    /// nowhere once 03-board-ui.md collapses the lane to a single trash entry, and nothing
    /// invisible may stay selected, drag-included, or pending-cut.
    public func resolved(against snapshot: BoardModel) -> Selection {
        guard !ids.isEmpty else { return self }

        var survivors: Set<ItemID> = []
        survivors.reserveCapacity(ids.count)
        for lane in snapshot.lanes {
            if ids.contains(lane.id), Liveness(isDeleted: lane.isDeleted) == liveness {
                survivors.insert(lane.id)
            }
            for card in lane.cards where ids.contains(card.id) {
                if Liveness(isDeleted: lane.isDeleted || card.isDeleted) == liveness {
                    survivors.insert(card.id)
                }
            }
        }
        return Selection(ids: survivors, liveness: liveness)
    }
}

// MARK: - BoardStore

/// The per-board hub: one live snapshot, one reload pipeline, and the read-side conditions the
/// board window renders (02-architecture.md § Layering ▸ Components).
///
/// **It enforces the one-way flow — files → watcher → loader → store → views — by never mutating
/// its snapshot from the write path.** A user action runs the Writer, the Writer touches disk, the
/// watcher notices, and the change arrives here as a reload like any external edit. The app trusts
/// its own writes no more than anyone else's; that is what makes external editors and agents
/// first-class, and it is why there is no `snapshot` setter anywhere below `apply(_:generation:)`.
///
/// ### What this type actually owns
///
/// 1. **The reload pipeline.** At most one tree walk in flight, off the main actor; signals arriving
///    during one coalesce into a single follow-up; only the newest result applies.
/// 2. **The failure rules.** A failed reload never replaces a good snapshot; an ordinary failure
///    raises the banner condition and leaves editing alone; a failure after a bracketed wholesale
///    operation locks the board read-only; the next success clears both.
/// 3. **Selection across reloads.** Re-resolved by UUID and liveness on every applied snapshot.
///
/// ### What it deliberately does not own
///
/// The `FolderWatcher` itself — the registry owns one watcher and one store per board and wires
/// them together (`watcherBrackets`, `handleWatcherEvent(_:)`), so this type can be built and tested
/// without a filesystem stream. The transient-state container is also still to come: selection lives
/// here for now, and the **new-card placeholder** will live beside it — a pseudo-card with no disk
/// presence and no UUID, overlaid on the snapshot rather than merged into it (02-architecture.md §
/// Layering, the one named exception to the one-way flow). Nothing here precludes that: `snapshot`
/// is a pure value swap with no identity assumptions, so an overlay can simply be rendered on top of
/// whatever the latest reload produced.
@MainActor
@Observable
public final class BoardStore {

    // MARK: Read-side state

    /// The last good tree walk. Replaced wholesale by a successful reload and **never** by the write
    /// path — see the type's doc comment for why. A failed reload leaves it exactly as it was.
    public private(set) var snapshot: BoardModel

    /// Tolerated anomalies from the load that produced `snapshot` (stray folders, an indexless
    /// UUID-shaped folder, a board-level `deleted:`). Replaced with the snapshot, so they always
    /// describe the tree currently on screen.
    public private(set) var loadWarnings: [LoadWarning]

    /// The standing read-side condition: the error from the last reload that failed, `nil` when the
    /// board is healthy. `BoardLoadError` already carries fail-fast's specifics — the offending path
    /// and what is wrong with it — which is the whole of what the banner needs to render
    /// (02-architecture.md § Live-reload resilience). This is a *condition*, not a one-shot: it
    /// stands until a reload succeeds, and it heals without ceremony when one does.
    public private(set) var reloadFailure: BoardLoadError?

    /// Non-`nil` while the board refuses writes. Cleared by the next successful reload, per "the
    /// next successful reload clears both the banner and the lock".
    public private(set) var readOnlyLock: ReadOnlyLockReason?

    public var isReadOnly: Bool { readOnlyLock != nil }

    /// The board's selection, re-resolved against every snapshot this store applies.
    public private(set) var selection: Selection

    /// Where the board is **now**. Follows the folder: a rename or a move absorbed through
    /// `relocate(to:)` updates it, so every URL derived from it — the Writer's paths, card-window
    /// keys, Reveal in Finder — re-derives at the new location (02-architecture.md §
    /// Write-failure surfacing, "A renamed or moved board root follows its file identity").
    ///
    /// Observed, deliberately: the window title's folder-name fallback reads this, and a rename in
    /// Finder should be visible in the title bar without anything else being told.
    ///
    /// `snapshot.rootURL` is the root the *last successful reload* walked, and therefore lags this
    /// by exactly one reload during an absorption. That is not a second source of truth: the
    /// relocation is always followed by the watcher reattach whose reconciling reload rebuilds the
    /// snapshot at the new root, after which the two agree again.
    public private(set) var rootURL: URL

    // MARK: Banners

    /// The board window's banner strip, as a model (02-architecture.md § The banner surface).
    ///
    /// **Owned, not injected**, and the reason is the hosting rule: the strip is "hosted by the
    /// window of origin", and a board window's strip has exactly one lifetime — this store's. A
    /// card window (m6) gets its *own* center for its own save, attachment, and raw-source Apply
    /// failures, and re-homes those rows here when it closes; injecting a shared center would
    /// erase precisely that distinction.
    ///
    /// It holds only what nothing else does — one-shot write failures, the history suspension,
    /// in-progress operations, passive signposts. The lock and the reload breakage stay this
    /// store's own state and are composed in at render time by `bannerRows`.
    public let banners = BannerCenter()

    /// The rows the board window's strip renders, in precedence order.
    ///
    /// Composed rather than stored: `readOnlyLock` and `reloadFailure` are the store's truths and
    /// `banners` holds the rest, so a stored array would be a third copy waiting to go stale. The
    /// ordering rule itself lives in `BannerCenter.rows(...)`, which is pure and tested on its own.
    public var bannerRows: [BannerRow] {
        BannerCenter.rows(
            lock: readOnlyLock,
            breakage: reloadFailure,
            oneShots: banners.oneShots,
            suspension: banners.historySuspension,
            operations: banners.operations,
            signposts: banners.signposts
        )
    }

    // MARK: Wiring

    /// The watcher's bracket calls, injected rather than owned: the registry holds the watcher and
    /// the store together, and a store that reached into a watcher it did not own could not be
    /// tested without one. `nil` means "no watcher attached" — every operation below still behaves,
    /// it simply has nothing to suspend, which is exactly the shape unit tests want.
    @ObservationIgnored
    public var watcherBrackets: (begin: @MainActor () -> Void, end: @MainActor () -> Void)?

    /// What to do when the watched root changes identity — injected for the same reason the
    /// brackets are: the response needs the board's **security-scoped bookmark**, and this store
    /// does not own one (the registry does, along with the watcher that must be re-attached and the
    /// last-known path that arms the return detection). A store that reached for a bookmark it did
    /// not hold could not be built or tested without a registry.
    ///
    /// `nil` keeps the documented no-op: the last-good snapshot stays on screen, which is what
    /// every other failure path here does, and which is exactly the shape unit tests and any
    /// storeless use want. `BoardStoreRegistry` wires it to its own recovery loop.
    @ObservationIgnored
    public var rootChangeDelegate: (@MainActor () -> Void)?

    // MARK: Reload machinery

    /// Monotonic id of the most recently *started* reload — and therefore also the number of tree
    /// walks this store has run since it opened, which is what makes the coalescing rule assertable
    /// from a test rather than merely plausible.
    ///
    /// Two rules are expressed as comparisons against it: **only the newest result applies** (a
    /// result whose generation is no longer the current one is dropped), and **the wholesale
    /// expectation binds to a load started after it was armed** (`wholesaleReloadFloor`).
    @ObservationIgnored
    private(set) var reloadGeneration = 0

    /// Whether a tree walk is running. At most one ever is: a second concurrent walk would buy
    /// nothing (both would produce the same snapshot) and would make "the newest result wins" a race
    /// rather than a rule.
    @ObservationIgnored
    private var reloadInFlight = false

    /// A reload owed but not started, because one was already running when the signal arrived — a
    /// **flag, not a queue**: any number of signals during one walk coalesce into exactly one
    /// follow-up, because the follow-up is a full tree walk that covers all of them. The origin is
    /// merged by the watcher's own precedence rule (`reconciling > appMediated > foreign`) so a
    /// foreign event folding into a pending reconciling one never downgrades it.
    @ObservationIgnored
    private var pendingReload: WatchOrigin?

    /// The generation from which a bracketed wholesale operation's expectation applies, or `nil`
    /// when no operation is outstanding.
    ///
    /// **Why a floor and not a boolean.** The rule is "the reload that ends this wholesale operation
    /// must succeed, or the board locks" — and a boolean cannot tell that reload apart from one that
    /// was *already in flight* when the operation ended, which observed a tree from before the
    /// operation touched it and therefore proves nothing about the result. Arming stores
    /// `reloadGeneration + 1`: the next walk to be *started*. An in-flight walk's generation is
    /// below the floor and passes through without consuming it; the first walk started at or after
    /// it consumes it — succeeding clears everything as usual, failing engages the lock.
    ///
    /// Ordinary (non-wholesale) reload failures never set the lock, because ordinary breakage is
    /// per-file: the snapshot still describes the tree and editing around the broken file is safe.
    @ObservationIgnored
    private var wholesaleReloadFloor: Int?

    /// Consumers suspended in `awaitQuiescence()`, resumed together the moment nothing is running
    /// and nothing is owed.
    @ObservationIgnored
    private var quiescenceWaiters: [CheckedContinuation<Void, Never>] = []

    /// Awaited off the main actor **after** a tree walk finishes and **before** its result is
    /// applied — the one seam this type keeps, `nil` in production.
    ///
    /// It exists because two of the contracts above are ordering claims about work that runs
    /// concurrently with the main actor ("only the newest result applies"; "the wholesale
    /// expectation never binds to a walk already in flight"), and a test that cannot pin a finished
    /// walk open can only approximate them with sleeps — which would make the suite slow, flaky, and
    /// silent about the very race it exists to rule out.
    @ObservationIgnored
    var loadBarrier: (@Sendable () async -> Void)?

    private static let logger = Logger(subsystem: "dev.rzen.indie.Kanban", category: "store")

    // MARK: - Init

    /// Opens a board: one synchronous tree walk, and **no fallback if it fails**.
    ///
    /// Fail-fast is the *initial-load* contract (01-storage-format.md § Malformed input): there is
    /// no last-good snapshot to keep on screen yet, so a broken board throws its `BoardLoadError`
    /// instead of constructing a store that would have nothing to show. Every rule below — the
    /// banner, the lock, "a failed reload never replaces a good snapshot" — exists only *because*
    /// this one succeeded.
    ///
    /// The walk is synchronous because the caller has nothing to render until it lands; the
    /// asynchronous, off-main pipeline starts with the first reload.
    public init(rootURL: URL) throws(BoardLoadError) {
        let result = try BoardLoader.load(boardRoot: rootURL)
        self.rootURL = rootURL
        self.snapshot = result.model
        self.loadWarnings = result.warnings
        self.reloadFailure = nil
        self.readOnlyLock = nil
        self.selection = .empty
    }

    // MARK: - Inbound signals

    /// The single inbound signal — everything the outside world tells this store arrives here.
    ///
    /// Deliberately one door: the watcher, a test, and (later) the registry's wake/activation
    /// reconciliation all speak the same two-case vocabulary, so there is exactly one place where a
    /// filesystem change becomes a reload.
    public func handleWatcherEvent(_ event: WatcherEvent) {
        switch event {
        case let .treeChanged(origin):
            requestReload(origin)

        case .rootChanged:
            // Delegated, never guessed at. The settled response is to re-resolve the board's
            // security-scoped bookmark and either `reattach(to:)` the watcher at the new location —
            // a rename absorbed with no banner and no lock — or enter the vanished-root read-only
            // lock (02-architecture.md § Write-failure surfacing). Both halves need a bookmark this
            // store does not own, and a guess here would be a *wrong* guess: treating a rename as a
            // vanish would lock a board that is merely somewhere else.
            //
            // No reload is started either way. The delegate's two outcomes both end in one —
            // `reattach(to:)`'s reconciling reload at the re-resolved root, or the vanished-root
            // lock's eventual clearance when the root returns — and a reload fired from here would
            // walk a path that just stopped being the board.
            guard let rootChangeDelegate else {
                Self.logger.debug("rootChanged ignored — no delegate is wired (storeless use)")
                return
            }
            rootChangeDelegate()
        }
    }

    /// Starts a reload, or banks one if a walk is already running.
    private func requestReload(_ origin: WatchOrigin) {
        guard !reloadInFlight else {
            pendingReload = WatchOrigin.merged(pendingReload, origin)
            return
        }
        startReload(origin)
    }

    // MARK: - The reload pipeline

    /// Runs one tree walk **off the main actor** and applies its result on it.
    ///
    /// Off-main because a board of any size is a directory walk plus a YAML parse per item, and the
    /// whole point of the value-type snapshot is that this work can happen anywhere: `BoardLoader`
    /// is stateless statics and `LoadResult` is `Sendable`, so the only thing that has to be on the
    /// main actor is the assignment at the end. `Task.detached` rather than `Task { }`: a task
    /// created inside a `@MainActor` method inherits that isolation and would run the walk on the
    /// main actor — the exact thing this is avoiding.
    ///
    /// `origin` is not branched on: every reload is a full tree walk, so no origin is less safe than
    /// another. It is carried because the *policy* around a reload differs later — a `.reconciling`
    /// sweep re-probes writability (02-architecture.md § Write-failure surfacing) — and because it is
    /// the one thing that makes a reload's provenance legible in the log.
    private func startReload(_ origin: WatchOrigin) {
        reloadGeneration += 1
        let generation = reloadGeneration
        let root = rootURL
        let barrier = loadBarrier
        reloadInFlight = true
        Self.logger.debug("reload \(generation, privacy: .public) started (\(origin.rawValue, privacy: .public))")

        Task.detached(priority: .userInitiated) { [weak self] in
            // `do throws(BoardLoadError)`: without the annotation the `catch` widens to `any Error`
            // and the loader's typed error is lost on the way into `Result`.
            let outcome: Result<LoadResult, BoardLoadError>
            do throws(BoardLoadError) {
                outcome = .success(try BoardLoader.load(boardRoot: root))
            } catch {
                outcome = .failure(error)
            }
            await barrier?()
            await self?.apply(outcome, generation: generation, origin: origin)
        }
    }

    /// Lands one walk's result and starts whatever it uncovered.
    private func apply(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
        reloadInFlight = false

        // The stale-apply guard. Serialization means this should not trigger today, but "only the
        // newest result applies" is the rule the wholesale floor and every future overlapping-load
        // change are written against, so it is enforced rather than assumed.
        if generation == reloadGeneration {
            land(outcome, generation: generation, origin: origin)
        }

        startPendingReload()
        resumeQuiescenceWaitersIfQuiet()
    }

    private func land(_ outcome: Result<LoadResult, BoardLoadError>, generation: Int, origin: WatchOrigin) {
        // Consumed here, before the branch, because *both* outcomes end the expectation: a wholesale
        // operation gets exactly one reload to prove itself, and a second failure after it is
        // ordinary per-file breakage again.
        let endsWholesaleOperation: Bool
        if let floor = wholesaleReloadFloor, generation >= floor {
            wholesaleReloadFloor = nil
            endsWholesaleOperation = true
        } else {
            endsWholesaleOperation = false
        }

        switch outcome {
        case let .success(result):
            snapshot = result.model
            loadWarnings = result.warnings
            // Breakage always heals on a success — it *is* the claim "the last reload failed", and
            // this one did not.
            reloadFailure = nil
            clearLockIfDisproved(by: origin)
            selection = selection.resolved(against: result.model)

        case let .failure(error):
            // `snapshot`, `loadWarnings` and `selection` are untouched: a failed reload never
            // replaces a good snapshot, and a selection over a snapshot that did not change has
            // nothing to re-resolve against.
            reloadFailure = error
            // `readOnlyLock == nil` rather than an unconditional assignment: a root that vanished
            // mid-bracket already raised its own, truer lock, and 02-architecture.md is explicit
            // that the bracket's final reload becomes a no-op there rather than a redundant
            // failure. Overwriting `.vanishedRoot` with `.bracketedReloadFailed` would also break
            // the clearing rules — the vanished root's lock must not clear on a reload that never
            // proves the root came back.
            if endsWholesaleOperation, readOnlyLock == nil {
                readOnlyLock = .bracketedReloadFailed
            }
            Self.logger.error("reload \(generation, privacy: .public) failed: \(error.description, privacy: .public)")
        }
    }

    private func startPendingReload() {
        guard !reloadInFlight, let origin = pendingReload else { return }
        pendingReload = nil
        startReload(origin)
    }

    // MARK: - The lock's clearing rules

    /// Clears the read-only lock if this successful reload actually disproved its cause.
    ///
    /// **Reason-specific, because the causes are not alike** (02-architecture.md § Write-failure
    /// surfacing):
    ///
    /// - `.bracketedReloadFailed` and `.vanishedRoot` are *disproved by the success itself*. The
    ///   first says "the tree could not be re-read after a wholesale change" and the second says
    ///   "the root is gone" — a completed tree walk at the root contradicts both, whatever origin
    ///   asked for it, so any success clears them.
    /// - `.unwritableLocation` is not. A board on a read-only DMG reloads flawlessly forever;
    ///   loading proves nothing about writing. It clears only when a **reconciling** reload — wake,
    ///   activation, a stream re-creation — re-probes writability and finds it changed ("Writability
    ///   re-probes on every reconciling reload, so a fixed permission or rewritable remount clears
    ///   the lock without ceremony").
    ///
    /// `FileManager.isWritableFile(atPath:)` is `access(2)` on the root directory: a real-uid
    /// permission question asked of the filesystem, which is what makes it answer correctly for
    /// both halves of the case — a read-only *mount* and a permission-denied *folder*.
    ///
    /// Deliberately **one-way**: a reconciling reload that finds the root unwritable does not
    /// *raise* the lock. Arming it is the open flow's job (`enterUnwritableLock()`), and inferring
    /// a lock from a probe here would be a policy decision this milestone was not asked to make.
    private func clearLockIfDisproved(by origin: WatchOrigin) {
        switch readOnlyLock {
        case nil:
            break
        case .bracketedReloadFailed, .vanishedRoot:
            readOnlyLock = nil
        case .unwritableLocation:
            guard origin == .reconciling, FileManager.default.isWritableFile(atPath: rootURL.path) else { return }
            Self.logger.debug("writability re-probe passed — the unwritable-location lock clears")
            readOnlyLock = nil
        }
    }

    // MARK: - Root identity and explicit locks

    /// Points this store at the board's new location, absorbing a rename or a move.
    ///
    /// Called by the registry when a `.rootChanged` re-resolved the board's bookmark somewhere else
    /// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
    /// file identity"): the board the app has open is the *file*, not the path string, so this is
    /// bookkeeping rather than an event — **no banner, no lock, nothing was ever wrong**.
    ///
    /// It deliberately starts **no reload**. The caller follows this with the watcher's
    /// `reattach(to:)`, whose reconciling reload is the one that rebuilds the snapshot at the new
    /// root; a reload fired from here would be a second walk racing that one for no gain. Until it
    /// lands, `rootURL` is the new location and `snapshot.rootURL` is still the old — see
    /// `rootURL`'s note.
    public func relocate(to newRoot: URL) {
        guard newRoot != rootURL else { return }
        Self.logger.debug("board root relocated; Writer URLs now derive from the new location")
        rootURL = newRoot
    }

    /// Raises the vanished-root read-only lock — the registry's call, after bookmark re-resolution
    /// found nothing and the last-known path is not there either.
    ///
    /// Overwrites whatever lock was standing: a root that is gone is the most current and most
    /// specific truth about why writes are refused, and its clearing rule (a successful reload,
    /// which can only happen if the root came back) is strictly the safer one to be holding.
    public func enterVanishedRootLock() {
        Self.logger.error("board root vanished — entering the read-only lock")
        readOnlyLock = .vanishedRoot
    }

    /// Raises the unwritable-location read-only lock — the open flow's call, after probing the
    /// root's writability (02 § "An unwritable board location enters the read-only lock at open").
    /// Public now so the vocabulary and its clearing rule ship together; m4's open flow is the
    /// producer.
    ///
    /// Does **not** overwrite a standing lock: a board that is already locked for a vanished root
    /// or a failed bracketed reload has a cause that outranks "and it is also read-only", and both
    /// of those clear on a success that would then re-probe anyway.
    public func enterUnwritableLock() {
        guard readOnlyLock == nil else { return }
        Self.logger.error("board location is not writable — entering the read-only lock")
        readOnlyLock = .unwritableLocation
    }

    // MARK: - Write gate

    /// Runs a synchronous Writer operation inside the watcher bracket, so the churn it produces
    /// rounds back as one app-mediated reload rather than a scatter of foreign ones.
    ///
    /// The store does **not** touch its snapshot here, before or after. `operation` puts bytes on
    /// disk; the watcher notices; the reload applies. That indirection is the one-way flow, and it is
    /// why this method's only jobs are the gate and the bracket.
    ///
    /// **A failure posts to the banner before it is rethrown** (02-architecture.md § Write-failure
    /// surfacing): the strip is how the one-way flow keeps its honesty — the action visibly did not
    /// happen, and the banner is the only thing that says why — so no call site is trusted to
    /// remember, and a `try?` at some future call site cannot make a failure silent. The refusal
    /// below is deliberately *not* posted: the lock row is already standing, and a second row per
    /// refused gesture would bury it under echoes of itself.
    ///
    /// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is locked read-only — checked
    ///   *before* the bracket opens, so a refusal costs no suspended watcher and no owed reload.
    ///   Otherwise rethrows whatever `operation` threw, which is a `BoardWriteError`. The untyped
    ///   `throws` is the price of those being two different error types today; see
    ///   `BoardStoreWriteRefusal` for why they are, and why they will not stay that way.
    ///
    /// One ergonomic wart, recorded so it is not rediscovered: when `operation` returns a value,
    /// Swift cannot infer `T` and the closure's thrown type at the same time — the thrown type
    /// widens to `any Error` and the call fails to compile. Such a call site spells the closure out
    /// (`{ () throws(BoardWriteError) -> ItemID in … }`). `Void`-returning operations, which are
    /// most of them, infer cleanly.
    @discardableResult
    public func performWrite<T>(_ operation: () throws(BoardWriteError) -> T) throws -> T {
        if let readOnlyLock {
            throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
        }
        watcherBrackets?.begin()
        // `defer`, not a trailing call: a Writer operation that fails partway has still touched disk,
        // and an unbalanced bracket would leave the watcher suspended for the rest of the session.
        defer { watcherBrackets?.end() }
        // `do throws(BoardWriteError)`: without the annotation the `catch` widens to `any Error` and
        // the Writer's typed error is lost on the way to the banner.
        do throws(BoardWriteError) {
            return try operation()
        } catch {
            banners.post(error)
            throw error
        }
    }

    /// Runs an operation that rewrites the tree **wholesale** — pull-rebase, branch switch, undo
    /// restore (06-history-undo.md, 07-sync-collab.md) — under the bracket, and arms the rule that
    /// its closing reload must succeed.
    ///
    /// Two things distinguish this from `performWrite`:
    ///
    /// - The bracket is load-bearing rather than tidy: a reload landing mid-operation would render a
    ///   half-checked-out tree.
    /// - Failure of the closing reload **locks the board** (`ReadOnlyLockReason.bracketedReloadFailed`).
    ///   After a wholesale change the last-good snapshot may describe a different branch entirely, so
    ///   writes derived from it would land nonsense — unlike ordinary per-file breakage, where the
    ///   snapshot still describes the tree.
    ///
    /// The expectation is armed on **every** exit path, a thrown error included: an operation that
    /// died partway is precisely the case where the tree's state is unknown and the next reload had
    /// better be the authority on it.
    ///
    /// - Throws: `BoardStoreWriteRefusal.readOnlyLocked` if the board is already locked — a locked
    ///   board refuses to *start* wholesale work, not just ordinary writes. Otherwise rethrows
    ///   `operation`'s error. (Spelled `throws` rather than `rethrows` because of that refusal: a
    ///   `rethrows` function may only throw errors its closure threw.)
    public func performWholesale(_ operation: () throws -> Void) throws {
        if let readOnlyLock {
            throw BoardStoreWriteRefusal.readOnlyLocked(readOnlyLock)
        }
        watcherBrackets?.begin()
        defer {
            // Ordered: arm first, then close the bracket. `endBracket()` is what schedules the
            // post-bracket reload, and with a `nil` watcher a consumer may signal by hand the instant
            // this returns — either way the floor has to be in place before any walk can start.
            wholesaleReloadFloor = reloadGeneration + 1
            watcherBrackets?.end()
        }
        do {
            try operation()
        } catch let error as BoardWriteError {
            // Same honesty rule as `performWrite`, applied to the one error type the banner has
            // phrasing for. A wholesale operation is usually git's (m7), whose own failure
            // vocabulary is not `BoardWriteError` and whose surfacing — the suspended-history
            // condition, the in-progress row swapping for an error — is the committer's to drive;
            // but a `BoardWriteError` escaping here is an ordinary failed write and may no more
            // bypass the strip than one from `performWrite`.
            banners.post(error)
            throw error
        }
    }

    // MARK: - Selection

    /// Replaces the selection.
    ///
    /// Minimal on purpose — the transient-state container will absorb this along with drag state and
    /// the pending cut, and give the selection its real grammar (extend, range, successor-on-delete).
    /// Deliberately *not* filtered against the snapshot: a caller selects what it is rendering, and
    /// `Selection.resolved(against:)` on the next reload is what keeps the set honest over time.
    public func select(_ ids: Set<ItemID>, liveness: Liveness) {
        selection = Selection(ids: ids, liveness: liveness)
    }

    /// Selects nothing — Escape's last step outward (04-interactions.md ▸ Grammar).
    public func clearSelection() {
        selection = .empty
    }

    // MARK: - Quiescence

    /// Suspends until no reload is running and none is owed.
    ///
    /// The store is not a request/response object — a signal in does not produce a result out — so
    /// this is how a consumer (and every test below) says "let the pipeline settle" without polling.
    /// Returns immediately when the store is already quiet.
    public func awaitQuiescence() async {
        guard !isQuiescent else { return }
        await withCheckedContinuation { continuation in
            quiescenceWaiters.append(continuation)
        }
    }

    private var isQuiescent: Bool { !reloadInFlight && pendingReload == nil }

    private func resumeQuiescenceWaitersIfQuiet() {
        guard isQuiescent, !quiescenceWaiters.isEmpty else { return }
        let waiters = quiescenceWaiters
        quiescenceWaiters.removeAll()
        for waiter in waiters {
            waiter.resume()
        }
    }
}
