import Foundation
import os

// MARK: - File identity

/// A file's identity as the filesystem understands it — the opaque token behind
/// `URLResourceKey.fileResourceIdentifierKey` — wrapped so it can key a dictionary.
///
/// **Both registries in this folder key on this, never on a path string** (02-architecture.md
/// § Per-board app state, "Keyed by file identity, never by path"). A board is the *folder*, not
/// the string that currently names it: Finder renames are ordinary (01-storage-format.md), and a
/// board renamed while open — or reopened through a moved path — must land on the store and the
/// record it already has rather than on a second copy of itself.
///
/// The token is documented as opaque and comparable only with `isEqual:`; nothing here reads into
/// it. `hash` is `NSObject`'s, which is the hash that agrees with `isEqual:` — the pair is what
/// makes this usable as a `Hashable` key at all.
///
/// **Not `Sendable`, deliberately**: the token is an Objective-C object with no concurrency
/// contract. Both registries are `@MainActor`, so it never leaves that actor, and claiming
/// otherwise would be an unchecked promise for no gain.
struct FileIdentity: Hashable {
    private let token: NSObject

    /// `nil` when the URL has no identity to read — it does not exist, or its volume cannot answer.
    ///
    /// Failable rather than throwing on purpose: every caller here already has a better error to
    /// produce than "no resource value". `BoardStoreRegistry.acquire` lets `BoardStore`'s own
    /// fail-fast load speak (it produces the *right* `BoardLoadError` for a missing, unreadable, or
    /// non-directory root), and `BoardRegistry` treats an unreadable identity as "no match", which
    /// is exactly what it means there.
    init?(of url: URL) {
        guard
            let values = try? url.resourceValues(forKeys: [.fileResourceIdentifierKey]),
            let identifier = values.fileResourceIdentifier as? NSObject
        else {
            return nil
        }
        token = identifier
    }

    static func == (lhs: FileIdentity, rhs: FileIdentity) -> Bool {
        lhs.token.isEqual(rhs.token)
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(token.hash)
    }
}

// MARK: - BoardStoreRegistry

/// The live side of "one board, one truth": every window showing a board shares **one**
/// `BoardStore` and **one** `FolderWatcher`, and this is what hands them out
/// (02-architecture.md § Layering ▸ Components).
///
/// ### Why a refcount when the answer is already known
///
/// **The board window owns the board** (settled, § Components): card windows never outlive it, so
/// the last release and the board window's close always coincide — the count could in principle be
/// replaced by "the board window closed". It earns its keep anyway, and only, as *ordering*:
/// closing a board window closes its card windows too, and those closes arrive as a handful of
/// separate SwiftUI teardowns. The count is what stops the first of them from stopping the watcher
/// out from under the ones still on screen.
///
/// ### What it owns, and what it deliberately does not
///
/// It owns the pairing: it creates the store, creates and starts the watcher, and wires them to
/// each other. It owns no policy — every reload rule lives in `BoardStore`, every debounce and
/// bracket rule in `FolderWatcher`. It is not a singleton either: the app holds one instance, so a
/// test can hold its own without the two colliding.
///
/// ### The app's own instance is the only one that matters
///
/// Two registries over the same board would defeat the point (two stores, two watchers, two
/// snapshots drifting apart). That is a wiring rule for the app, not something this type can
/// enforce, and it is stated here rather than defended in code.
@MainActor
public final class BoardStoreRegistry {

    /// One open board: the shared store, the watcher feeding it, how many windows are holding them
    /// open — and the two facts the root-recovery loop needs.
    private struct Entry {
        let store: BoardStore
        let watcher: FolderWatcher
        var referenceCount: Int

        /// The board's **identity**, minted at acquire (02-architecture.md § Write-failure
        /// surfacing: "the registry's security-scoped bookmark is the identity, mid-session as much
        /// as across opens"). Re-minted on every absorbed rename, so it always names the folder
        /// where the board is now rather than where it was opened.
        ///
        /// Optional because bookmark creation can fail outright — vanishingly unlikely for a folder
        /// just walked, and survivable: a board with no bookmark simply falls through to the
        /// path-reappearance half of the recovery loop, which is the same path a *dead* bookmark
        /// takes.
        var bookmark: Data?

        /// Where the board was last known to live. The vanished-root case re-attaches the watcher
        /// here — FSEvents is content to watch a path that does not exist and reports its
        /// *creation* as a root change, which is exactly the return detection the lock needs.
        var lastKnownRoot: URL
    }

    /// Keyed by `FileIdentity`, so a board acquired through a renamed or moved path finds the entry
    /// it already has. A `[URL: Entry]` here would be a latent bug with a plausible shape.
    private var entries: [FileIdentity: Entry] = [:]

    /// **The open walk's single flight** — one board, one tree walk, however many windows ask for it
    /// at once (02-architecture.md § Launch and window lifecycle: "Restoration is parallel … each
    /// walk independent").
    ///
    /// Keyed by the same `FileIdentity` the entries are, which is the whole reason it lives here
    /// rather than in a caller: the registry already knows that two differently-spelled URLs are one
    /// board. Restoration racing a Finder open of the same root, or a board window and its restored
    /// card window arriving together, therefore costs **one** walk and produces **one** store — the
    /// prior art is the store's own reload coalescing (`requestReload`), applied to the load that
    /// happens before a store exists to coalesce on.
    ///
    /// **Per board, never global**: a slow network board's walk holds nothing but its own key, so
    /// every other board opening at the same moment proceeds untouched.
    ///
    /// The task's failure type is `Never` and its value is a `Result` — `Task`'s typed-throws
    /// initializers do not exist, and widening `BoardLoadFailure` to `any Error` on the way through
    /// the join would lose exactly the aggregate the decision surface is made of.
    private var walksInFlight: [FileIdentity: Task<Result<LoadResult, BoardLoadFailure>, Never>] = [:]

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

    /// The app owns one instance and passes it down; there is no shared singleton to reach for.
    public init() {}

    /// How many boards are open — entries, not references. This is the welcome-window and
    /// quit-time question ("is anything still open?"), never the refcount.
    public var openBoardCount: Int { entries.count }

    // MARK: - Acquire / release

    /// The store for `rootURL`, opening the board if this is the first window to ask for it —
    /// **walking on the caller's own actor**.
    ///
    /// The synchronous acquire, and still the right one for every caller that cannot render anything
    /// before the board exists: a card window joining a board that is already open (the walk never
    /// runs there), the storeless surfaces, and the tests. A **board window** takes
    /// `acquireOffMain(_:)` instead — 02's pre-snapshot loading state gave that caller something to
    /// show while the walk runs, which is the only reason a walk on the main actor was ever
    /// tolerable.
    ///
    /// **First acquire**: loads the board (fail-fast — the `BoardLoadFailure` is rethrown untouched,
    /// every collected defect included, because the decision surface is the caller's to host —
    /// because there is nothing to render and nothing to fall back on), then creates and starts the
    /// watcher and ties the two together in both directions — watcher events into
    /// `BoardStore.handleWatcherEvent(_:)`, the store's write brackets out to
    /// `FolderWatcher.beginBracket()`/`endBracket()`.
    ///
    /// **Subsequent acquires** return the *same* store and bump the count. Not a fresh load, not a
    /// second watcher: a card window opening must not cost a tree walk, and two watchers over one
    /// board would double every reload for nothing.
    ///
    /// The identity read happens *before* the load so an already-open board is found under whatever
    /// path it is being asked for now. An identity that cannot be read is not decided here — it
    /// falls through to `BoardStore.init`, whose load produces the honest error for a root that is
    /// missing, unreadable, or not a directory. This type invents no error of its own.
    ///
    /// A failed load leaves **nothing behind**: no entry, no watcher, no count. A board that failed
    /// to open is not open.
    public func acquire(_ rootURL: URL) throws(BoardLoadFailure) -> BoardStore {
        if let store = referenceExistingBoard(at: rootURL) { return store }
        return try adopt(BoardStore(rootURL: rootURL), rootURL: rootURL)
    }

    /// The **board window's** acquire: the tree walk runs off the main actor, and concurrent asks
    /// for one board share it (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29).
    ///
    /// ### What it does, in the order it does it
    ///
    /// 1. **An already-open board returns immediately**, refcount bumped, no walk — `acquire`'s own
    ///    first act, unchanged and still synchronous: this is the card-window and second-window path
    ///    and it must not cost a suspension, let alone a directory walk.
    /// 2. **Otherwise one walk**, on a detached task at `.userInitiated`, joined by every other
    ///    async acquire of the same board that arrives while it runs (`walksInFlight`).
    /// 3. **The result lands back here on the main actor** and is turned into the store — through
    ///    the same `adopt` the synchronous path uses, so the probe, the watcher, the brackets, the
    ///    bookmark, the entry and the heals are the same wiring by construction rather than by two
    ///    lists that must be kept in step.
    ///
    /// ### Cancellation is discard-on-completion
    ///
    /// ⌘W during the pre-snapshot loading state cancels the *caller's* task ("the walk is
    /// cancellable: ⌘W during loading cancels it and closes the window"). The walk itself is one
    /// synchronous `BoardLoader.load` call and is deliberately **not** made cooperatively
    /// cancellable: the loader would have to check a flag between every directory and every parse,
    /// and the reward would be a fraction of a second of a walk nobody is waiting for. So the window
    /// closes at once and the walk's tail is wasted work — accepted, and the whole of the cost.
    ///
    /// What cancellation *does* guarantee is that the discarded walk leaves nothing behind: this
    /// returns `nil` before constructing anything, so there is no store, no watcher, no entry and no
    /// reference — and the caller's security-scoped access is its own to release.
    ///
    /// - Parameter skipping: **the decision surface's consented skips** (01-storage-format.md
    ///   § Malformed input, ruled 2026-07-31), passed to the walk and then *retained by the store*
    ///   so every reload of the resulting session runs with the same set (`BoardStore.skippedPaths`).
    ///   Empty — the default — is every board that opens without a surface.
    ///
    /// - Returns: the board's store, or `nil` if this acquire was cancelled before its walk landed.
    ///   `nil` is not a failure: nothing went wrong and nothing was opened.
    public func acquireOffMain(
        _ rootURL: URL,
        skipping: Set<String> = []
    ) async throws(BoardLoadFailure) -> BoardStore? {
        if let store = referenceExistingBoard(at: rootURL) { return store }

        // `nil` for a root that does not exist or whose volume will not answer — there is nothing to
        // coalesce on, so such an open walks alone and the loader produces the honest error for it.
        //
        // **A skip-carrying acquire also walks alone**, deliberately: the single flight exists so
        // concurrent opens of one board share a walk, and two walks are only shareable when they
        // would produce the same answer. A skip set changes what the walk *finds*, so joining one
        // would hand a window a board someone else's consent composed — and a Repair-and-Open's
        // re-walk could be answered by the very walk that failed. The condition is exactly "an
        // ordinary open", which is every open the coalescing was built for (restoration, a Finder
        // open racing it, a card window arriving first) and none of the surface's.
        let identity = skipping.isEmpty ? FileIdentity(of: rootURL) : nil

        let walk: Task<Result<LoadResult, BoardLoadFailure>, Never>
        if let identity, let joined = walksInFlight[identity] {
            Self.logger.debug("acquire: joining the walk already running for this board")
            walk = joined
        } else {
            walk = Self.walk(rootURL, skipping: skipping)
            if let identity { walksInFlight[identity] = walk }
        }

        let outcome = await walk.value

        // Cleared by whichever joiner resumes first; the equality check is what stops a *later*
        // walk of the same board from being cleared by a straggler from the previous one.
        if let identity, walksInFlight[identity] == walk { walksInFlight[identity] = nil }

        // Discard-on-completion. Before the store is constructed, so a cancelled open leaves the
        // registry exactly as it found it.
        if Task.isCancelled {
            Self.logger.debug("acquire: cancelled before its walk landed — nothing opened")
            return nil
        }

        // A joiner that resumed first has already built the board; this is the same identity hit as
        // step 1, asked again because the world moved while this call was suspended. There is no
        // suspension between here and the entry's insertion in `adopt`, so two joiners cannot both
        // pass it.
        if let store = referenceExistingBoard(at: rootURL) { return store }

        switch outcome {
        case let .failure(failure):
            throw failure
        case let .success(result):
            return try adopt(
                BoardStore(rootURL: rootURL, loaded: result, skipping: skipping), rootURL: rootURL)
        }
    }

    /// One tree walk on a task of its own. `Task.detached` rather than `Task { }` for
    /// `BoardStore.startReload`'s reason exactly: a task created inside a `@MainActor` method
    /// inherits that isolation and would run the walk on the main actor, which is the whole thing
    /// this is avoiding.
    private static func walk(
        _ rootURL: URL,
        skipping: Set<String>
    ) -> Task<Result<LoadResult, BoardLoadFailure>, Never> {
        Task.detached(priority: .userInitiated) {
            do throws(BoardLoadFailure) {
                return .success(try BoardLoader.load(boardRoot: rootURL, skipping: skipping))
            } catch {
                return .failure(error)
            }
        }
    }

    /// The already-open answer, shared by both acquires: the store this board already has, with this
    /// caller's reference added. `nil` means "not open here", which is the only case that walks.
    private func referenceExistingBoard(at rootURL: URL) -> BoardStore? {
        guard let identity = FileIdentity(of: rootURL), var entry = entries[identity] else { return nil }
        entry.referenceCount += 1
        entries[identity] = entry
        Self.logger.debug("acquire: existing board, \(entry.referenceCount, privacy: .public) references")
        return entry.store
    }

    /// Everything a freshly loaded board needs before it is anyone's to show — the probe, the
    /// watcher, the wiring in both directions, the bookmark, the entry, the heals.
    ///
    /// **One body for both acquires.** How the store was built (a walk on this actor, or a walk that
    /// landed from a detached task) is the only difference between the two paths; everything from
    /// here down is identical, and identical by sharing rather than by resemblance.
    private func adopt(_ store: BoardStore, rootURL: URL) throws(BoardLoadFailure) -> BoardStore {
        // **The open-time writability probe** (02-architecture.md § Write-failure surfacing, "An
        // unwritable board location enters the read-only lock at open"), and this is the seam for
        // it: every way a board opens — welcome's recents, a Finder open, File ▸ Open, restoration,
        // a card window arriving first — funnels through one of the two acquires and so through
        // this method, so the probe is wired once here instead of at each caller, and no future open
        // path can forget it.
        //
        // **First, immediately after the load.** The lock has to be standing before anything else
        // in this method can act on the board, and two things below would otherwise write into a
        // location this line already knows is read-only: the loose-file relocation and the agent
        // guide. Ordering them after the probe is what makes 02's "the open-time agent-guide write
        // is skipped-with-log" true by construction rather than by that method's own second gate.
        //
        // The board still opens. This is a lock, not a refusal: the window comes up, the snapshot
        // renders, and reading, selecting, searching and copying out all stay live.
        store.probeWritabilityAtOpen()

        // Unreachable in practice — the load above just walked this directory — but the alternative
        // is a force-unwrap on a resource value the filesystem is free to refuse, so it is spelled
        // out. The loader's own vocabulary says it; no new error path is invented for a case that
        // means exactly what `unreadableRoot` already means.
        guard let identity = FileIdentity(of: rootURL) else {
            throw BoardLoadFailure(
                BoardLoadError(path: ".", reason: .unreadableRoot(message: "the board root has no file identity")))
        }

        // Both directions of the wiring capture weakly, and the registry's entry is what keeps the
        // pair alive: the watcher never props up a store nobody is showing, and the store's brackets
        // become no-ops the moment `release` drops the watcher. Neither closure can form a cycle
        // with the object it calls into.
        let watcher = FolderWatcher(root: rootURL) { [weak store] event in
            // `.rootChanged` rides through like any other event; the store hands it straight back
            // here through `rootChangeDelegate`, below.
            store?.handleWatcherEvent(event)
        }
        store.watcherBrackets = (
            begin: { [weak watcher] in watcher?.beginBracket() },
            end: { [weak watcher] in watcher?.endBracket() }
        )
        // The recovery loop's entry point. It captures the **store**, not the entry's key: an
        // absorbed rename can re-key the entry (a delete-and-recreate at the same path changes file
        // identity), and a closure holding a stale key would quietly stop finding its own board on
        // the second root change. Object identity cannot go stale.
        store.rootChangeDelegate = { [weak self, weak store] in
            guard let self, let store else { return }
            recoverFromRootChange(of: store)
        }

        // The bookmark is minted here — at acquire, once — because that is the moment the app
        // demonstrably has access to this folder (it just walked it). A bookmark minted later,
        // after the root has already gone missing, would be exactly the one that cannot be made.
        let bookmark = BoardRegistry.makeBookmark(for: rootURL)?.data
        if bookmark == nil {
            // Not a failure to open: the board loads, renders, and writes. Only the *rename*
            // half of root recovery is lost, and the path-reappearance half still works.
            Self.logger.error("no bookmark could be minted for this board; a mid-session move will not be absorbed")
        }

        // Default debounce and latency: the numbers are settled in 02-architecture.md § Components
        // (200 ms trailing over 50 ms FSEvents latency) and live in `FolderWatcher`'s defaults.
        // Restating them here would be a second place for them to drift.
        if !watcher.start() {
            // Not a failure to open. A board whose stream would not come up still loads, still
            // renders, and still writes — it simply will not notice foreign edits, which is the
            // same degradation 07-sync-collab.md already accepts on warned-against volumes. The
            // reconciling reloads that wake and activation drive are the recovery path.
            Self.logger.error("watcher stream failed to start; live reload is degraded for this board")
        }

        entries[identity] = Entry(
            store: store,
            watcher: watcher,
            referenceCount: 1,
            bookmark: bookmark,
            lastKnownRoot: rootURL
        )

        // **The heal engine's open seam** (02-architecture.md ▸ Components ▸ HealScheduler: "fires
        // uniformly at the reload tail and at registry acquire"). Everything an agent or a
        // hand-editor left in this board while it was closed is healed now: loose files relocated
        // into `attachments/`, legacy `deleted:` keys migrated, a squatter moved off a claimed name,
        // the agent guide brought up to version — each with its own notice where it has one.
        //
        // **Here rather than in `BoardStore.init`**, and last rather than first: the store's own
        // init is one tree walk and no writes, and a heal written before the brackets and the
        // watcher exist would be a write nothing is watching — landing on disk with the snapshot
        // above it left one reload stale. By this line the pair is wired, so each heal is an
        // ordinary bracketed app write whose echo reload refreshes the board like any other. Every
        // reload thereafter re-fires them from `BoardStore.land`; this call is only the one the
        // opening walk would otherwise have no reload behind.
        //
        // **One call, all four** — before 2026-07-29 this seam named two of the three healers by
        // hand, which is how the legacy-tombstone migration came to be the one heal that never fired
        // at open. A board opened, migrated nothing, and waited for an unrelated filesystem event to
        // do what opening should have done.
        store.runScheduledHeals()
        return store
    }

    /// Drops one window's reference; at zero the watcher stops and the entry goes.
    ///
    /// Looked up by **object identity of the store**, not by its `rootURL`: a board renamed while
    /// open keeps the `rootURL` it was opened with (see `BoardStore.rootURL`), so a path-based
    /// lookup would miss precisely the case the whole file-identity keying exists for, and leak the
    /// entry — a watcher running over a board with no windows.
    ///
    /// **Releasing a store this registry never handed out is a no-op**, not a trap. Teardown races
    /// are the ordinary shape of window closing (a card window and its board window closing in the
    /// same run loop turn, a double-dismiss, a store built directly in a test), and none of them is
    /// a programming error worth taking the app down for.
    public func release(_ store: BoardStore) {
        guard let identity = entries.first(where: { $0.value.store === store })?.key else {
            Self.logger.debug("release: store is not registered — ignored")
            return
        }

        guard var entry = entries[identity] else { return }
        entry.referenceCount -= 1
        guard entry.referenceCount <= 0 else {
            entries[identity] = entry
            return
        }

        entry.watcher.stop()
        // Explicit rather than left to the weak captures: after this the store may still be alive in
        // whatever is closing, and brackets that quietly did nothing would be a lie about a watcher
        // that is gone. The root-change delegate goes for the same reason — a released store has no
        // entry to recover, and leaving the closure attached would invite it to look for one.
        entry.store.watcherBrackets = nil
        entry.store.rootChangeDelegate = nil
        entries[identity] = nil
    }

    // MARK: - Root recovery

    /// The whole response to a root-gone signal, in the one object that can give it: the store to
    /// relocate or lock, the watcher to re-attach, and the bookmark that decides which
    /// (02-architecture.md § Write-failure surfacing, "A renamed or moved board root follows its
    /// file identity" and "A vanished board root locks the board read-only").
    ///
    /// ### Three outcomes, tried in order
    ///
    /// 1. **The bookmark resolves to a folder that is really there** — a rename or a move on the
    ///    same volume. Absorbed transparently: relocate, re-attach, re-mint. **No banner, no lock,
    ///    nothing was ever wrong.**
    /// 2. **The bookmark is dead, but the last-known path exists again** — a Finder undo, a
    ///    remount, a folder recreated where the board used to be, arriving as the *creation* of a
    ///    watched path. Treated as the root returning: the bookmark is re-minted from the path and
    ///    the same absorption runs, and the reconciling reload's success is what clears the
    ///    vanished-root lock through the store's own rule.
    /// 3. **Neither** — the root is truly vanished. The lock goes up and the watcher re-attaches at
    ///    the last-known path anyway: FSEvents happily watches a path that does not exist and
    ///    reports its creation as another `.rootChanged` (pinned in `FolderWatcherTests`), which is
    ///    what arms outcome 2 for later. The re-attach's reconciling reload fails against the
    ///    missing root, which is correct and harmless — a failed reload never replaces a good
    ///    snapshot, and the lock is already standing.
    ///
    /// ### Why outcome 1 checks the filesystem
    ///
    /// Bookmark resolution is not a liveness test. `BoardRegistryTests` says as much where it
    /// refuses to build a dead-bookmark case by deleting a folder ("'ought to' is the filesystem's
    /// opinion"): a bookmark can resolve by its recorded *path* to something that is no longer
    /// there. Absorbing that would relocate the board onto a ghost and leave it with a reload
    /// failure instead of the honest vanished-root lock, so a resolution that does not exist on
    /// disk falls through to outcomes 2 and 3.
    ///
    /// ### Re-entrancy
    ///
    /// Every outcome ends in a fresh stream, and every future root change lands right back here.
    /// That is the design, not an accident: outcome 3 arms outcome 2, and a board that is renamed
    /// twice is absorbed twice. Nothing here holds state between calls beyond the entry itself, and
    /// the entry is looked up by store identity on each pass, so a re-key mid-loop cannot strand a
    /// later signal.
    private func recoverFromRootChange(of store: BoardStore) {
        guard let identity = entries.first(where: { $0.value.store === store })?.key,
              let entry = entries[identity] else {
            Self.logger.debug("root change for a store that is no longer registered — ignored")
            return
        }

        // 1. The rename-absorption path.
        if let bookmark = entry.bookmark,
           let resolution = BoardRegistry.resolve(bookmark),
           BoardRegistry.withScopedAccess(to: resolution.url, { FileManager.default.fileExists(atPath: $0.path) }) {
            Self.logger.debug("root change resolved through the bookmark — absorbing the move")
            absorb(newRoot: resolution.url, at: identity)
            return
        }

        // 2. The root returning at its last-known path — Finder undo, remount, recreation.
        if FileManager.default.fileExists(atPath: entry.lastKnownRoot.path) {
            Self.logger.debug("root reappeared at its last-known path — absorbing the return")
            absorb(newRoot: entry.lastKnownRoot, at: identity)
            return
        }

        // 3. Truly vanished.
        store.enterVanishedRootLock()
        entry.watcher.reattach(to: entry.lastKnownRoot)
    }

    /// Moves an entry's board to `newRoot`: the store's URLs, the watcher's stream, the entry's
    /// bookmark and last-known path, and — where it changed — the entry's key.
    ///
    /// **Order matters.** `relocate(to:)` first, so the reconciling reload that `reattach(to:)`
    /// schedules walks the new root and lands a snapshot that agrees with the store's `rootURL`.
    /// Re-attaching first would leave a window in which the reload's tree and the Writer's URLs
    /// disagreed about where the board is.
    ///
    /// **Re-keying.** A rename keeps the folder's file identity, so the key is usually unchanged
    /// and this is a no-op. A board deleted and recreated at the same path is a *different inode*
    /// under the same name, and its entry has to move to the new identity or the next
    /// `acquire`/`liveStore(for:)` would miss it and open a second store over the board already on
    /// screen — the exact bug the identity keying exists to prevent. An identity that cannot be
    /// read leaves the key alone: a stale key still finds the entry by store identity, where
    /// dropping the entry would strand a live watcher.
    private func absorb(newRoot: URL, at identity: FileIdentity) {
        guard var entry = entries[identity] else { return }

        entry.store.relocate(to: newRoot)
        entry.watcher.reattach(to: newRoot)
        entry.lastKnownRoot = newRoot
        // Re-minted from where the board is now: a bookmark tracks moves, but a *stale* one is only
        // good for a while, and this is the moment the app has both access and certainty.
        if let refreshed = BoardRegistry.withScopedAccess(to: newRoot, { BoardRegistry.makeBookmark(for: $0) }) {
            entry.bookmark = refreshed.data
        }

        guard let currentIdentity = FileIdentity(of: newRoot), currentIdentity != identity else {
            entries[identity] = entry
            return
        }
        entries[identity] = nil
        entries[currentIdentity] = entry
        Self.logger.debug("board re-keyed: the folder at this path is a different file than it was")
    }

    /// The live store for a board, if any window has it open — **without** taking a reference.
    ///
    /// This is the "is this board already open?" question: focusing an existing window rather than
    /// opening a second one, routing a cross-board drag into a board that happens to be on screen.
    /// A bump here would be a leak, since nothing is being opened.
    public func liveStore(for rootURL: URL) -> BoardStore? {
        guard let identity = FileIdentity(of: rootURL) else { return nil }
        return entries[identity]?.store
    }
}
