import Foundation
import Observation
import os

/// The app's root model: which boards exist on this phone, where each one lives, and what state it
/// is in.
///
/// **One per process, owned by `MobileApp` and handed down through `.environment`.** It holds both
/// homes, the one `NSMetadataQuery` the app runs, and the `BoardSession` cache — all three are
/// process-wide facts, and a second instance would mean a second query over the same scope for no
/// gain.
///
/// ### Two homes, one list
///
/// Boards live either in the ubiquity container (`CloudHome`) or in the app's own `Documents/`
/// (`DeviceHomeResolver`), and the list is the merge of both, each entry tagged with where it came
/// from. The device home always resolves, so **the list is always reachable**: a phone with no iCloud
/// account gets an inline notice above its local boards rather than a wall in place of them
/// (softened 2026-08-08 — `cloudUnavailable` is what carries that notice, and `phase` no longer has
/// an unavailable case to carry it instead).
///
/// The two sides are found differently and always will be: the container is watched, the device
/// folder is enumerated. Every scan pass therefore takes whatever the cloud side has to offer — a
/// query result set, a directory listing under the DEBUG override, or nothing at all — and walks the
/// device root itself before merging.
///
/// ### The observer flow
///
/// The query is the only change signal the cloud side has on this platform (there is no FSEvents
/// here — see project.yml ▸ Lanework for iPhone). Both of its notifications are handled identically:
/// bracket the result set, pull `Sendable` descriptors out of it on the main actor, then scan and
/// assemble off it. The delta keys are deliberately unused — the board count is small enough that a
/// full re-enumeration is cheaper than the bookkeeping a delta needs, and re-enumeration cannot drift
/// out of sync with the container the way a maintained baseline can.
///
/// A gather is not treated as an arrival: it is the first full picture, and it publishes summaries
/// exactly as an update does. That differs from the sync-engine skill's monitor, which must baseline
/// silently because its consumer replays events into a cache — here the published state *is* the
/// query's result set merged with the device folder, so there is no replay to protect.
@MainActor
@Observable
final class BoardIndexStore {

    /// The three states the boards tab renders. **No unavailable case**: the device home cannot fail,
    /// so there is always a list to reach, and the one home that *can* fail reports through
    /// `cloudUnavailable` alongside a live list rather than in place of it.
    enum Phase: Sendable, Equatable {
        /// `start()` has not run.
        case idle

        /// Resolving the homes, or waiting for the first scan to land.
        case loading

        /// The list is live. `boards` may be empty; an empty phone is not a failure, it is the state
        /// the create-a-board bootstrap exists for.
        case ready
    }

    private(set) var phase: Phase = .idle

    /// Resolved at most once per process, and `nil` for as long as it has not been — which is a state
    /// the app runs in perfectly well, minus syncing.
    private(set) var home: CloudHome?

    /// Why there is no `home`, or `nil` when there is one. **The notice's whole input**: non-`nil`
    /// alongside `phase == .ready` is the ordinary no-iCloud run, not an error state.
    private(set) var cloudUnavailable: CloudHomeUnavailable?

    /// The sandbox's `Documents/` — the home that always answers. `nil` only before `start()`.
    private(set) var deviceRoot: URL?

    /// Every `.kanban` package in either home, sorted by title (case- and diacritic-insensitively,
    /// tie-broken by path so the order is total and stable across refreshes). Location is a tag on
    /// each row, not a grouping: two homes are an implementation detail of where a board's bytes are,
    /// and a person looking for a board is looking for its name.
    private(set) var boards: [BoardSummary] = []

    /// A scan is in flight. Distinct from `phase == .loading`, which is about there being nothing to
    /// show yet: this stays true through refreshes of a list that is already on screen.
    private(set) var isScanning = false

    /// The most recent failure that did not cost the whole list — a refused board create, a move that
    /// would not land. Cleared by the next successful operation of the same kind.
    private(set) var lastError: String?

    private var query: NSMetadataQuery?
    private var observers: [NSObjectProtocol] = []

    /// The in-flight container resolution, so the notice's retry button — and the two tabs that each
    /// call `start()` on appearance — cannot stack three of them on the daemon at once.
    private var cloudResolve: Task<Void, Never>?

    /// Only the newest scan applies. Serialization makes staleness unlikely rather than impossible —
    /// a metadata update landing while a scan runs starts a second one — so the guard is enforced
    /// rather than assumed, exactly as `BoardStore` does on the Mac.
    private var scanGeneration = 0

    /// The previous pass's **merged** entries, for the unchanged-notification gate in `scan(cloud:force:)`.
    /// Merged rather than cloud-only on purpose: gating on the query's answer alone would mean a board
    /// that appeared, vanished or was moved in the device home never reached the list, because nothing
    /// about it can change what the query reports.
    private var lastEntries: [BoardIndexEntry]?

    /// Open sessions, keyed by standardized root URL. Cached so navigating out of a board and back
    /// into it does not re-materialize and re-walk a package the app already has in hand.
    private var sessions: [URL: BoardSession] = [:]

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

    init() {}

    // MARK: - Lifecycle

    /// Resolves both homes and starts watching.
    ///
    /// The first call does the work. A later one is only ever the iCloud notice's retry — the one home
    /// that can fail, asked again — which is why it is safe for every screen to call this on
    /// appearance.
    func start() {
        guard case .idle = phase else {
            resolveCloudHome()
            return
        }

        phase = .loading
        Task { [weak self] in
            let root = await DeviceHomeResolver.resolve()
            guard let self else { return }
            self.deviceRoot = root
            self.resolveCloudHome()
        }
    }

    /// Asks for the ubiquity container: once at a time, and never again once it has answered with a
    /// home. A failure leaves the door open, because signing into iCloud is a thing that happens while
    /// an app is running.
    private func resolveCloudHome() {
        guard home == nil, cloudResolve == nil else { return }

        cloudResolve = Task { [weak self] in
            let resolution = await CloudHomeResolver.resolve()
            guard let self else { return }
            self.cloudResolve = nil

            switch resolution {
            case let .success(home):
                self.home = home
                self.cloudUnavailable = nil
                self.adopt(home)
            case let .failure(reason):
                self.cloudUnavailable = reason
                Self.logger.error("no cloud home: \(reason.description, privacy: .public)")
                // The device home is still a home, and landing its scan is what keeps both the list
                // and the phase real without iCloud. The notice above the list carries the rest.
                self.refresh()
            }
        }
    }

    /// Re-reads both homes now, whether or not anything looks different.
    ///
    /// On the cloud side under the metadata query this is a courtesy — the query already reports every
    /// change — and it is what pull-to-refresh calls. On the device side it is the *only* refresh there
    /// is, since a plain directory sends no notifications, and the same is true of the cloud side under
    /// `LANEWORK_LOCAL_ROOT`.
    func refresh() {
        scan(cloud: cloudSource(), force: true)
    }

    private func adopt(_ home: CloudHome) {
        guard home.isWatchable else {
            scan(cloud: .directory(home.documentsURL), force: true)
            return
        }
        startQuery()
    }

    /// What the cloud half of the next scan should read: the query's answer where there is a query, a
    /// directory listing under the DEBUG override, and nothing at all when iCloud never resolved.
    private func cloudSource() -> CloudScanSource {
        guard let home else { return .absent }
        if home.isWatchable, let query { return .query(readEntries(from: query)) }
        return .directory(home.documentsURL)
    }

    // MARK: - The metadata query

    /// The predicate is a filename match on `*.kanban`, and it matches **one item per board** rather
    /// than one per file inside it: the app exports `dev.rzen.indie.kanban-board` conforming to
    /// `com.apple.package` (KanbanMobile/Info.plist), which is what makes the daemon treat a `.kanban`
    /// directory as a single document. Without that export this query would return every `index.md`
    /// in every board.
    private func startQuery() {
        guard query == nil else { return }

        let query = NSMetadataQuery()
        query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
        query.predicate = NSPredicate(format: "%K LIKE %@", NSMetadataItemFSNameKey, "*.kanban")
        self.query = query

        observers = [
            observe(.NSMetadataQueryDidFinishGathering, from: query),
            observe(.NSMetadataQueryDidUpdate, from: query),
        ]

        // `NSMetadataQuery` is main-thread-only and delivers on the runloop that started it, which is
        // why this whole type is `@MainActor` — the isolation is the guarantee, not a convention.
        query.start()
    }

    /// The observer block is `@Sendable` and must not carry the `Notification` anywhere: the result
    /// set is re-read from the query instead, on the main thread the block is already on
    /// (`queue: .main` is what makes `assumeIsolated` sound here).
    private func observe(_ name: Notification.Name, from query: NSMetadataQuery) -> NSObjectProtocol {
        NotificationCenter.default.addObserver(forName: name, object: query, queue: .main) { [weak self] _ in
            MainActor.assumeIsolated {
                self?.queryDidFire()
            }
        }
    }

    /// Gather and update are handled identically — see the type's note on why there is no delta
    /// bookkeeping here.
    private func queryDidFire() {
        guard let query else { return }
        scan(cloud: .query(readEntries(from: query)), force: false)
    }

    /// Pulls plain values out of the result set. Bracketed by `disableUpdates`/`enableUpdates`
    /// because the query is free to swap its own storage mid-iteration otherwise, and nothing that
    /// leaves here is an `NSMetadataItem` — that type is not `Sendable` and must never cross to the
    /// scan.
    private func readEntries(from query: NSMetadataQuery) -> [BoardIndexEntry] {
        query.disableUpdates()
        defer { query.enableUpdates() }

        var entries: [BoardIndexEntry] = []
        entries.reserveCapacity(query.resultCount)
        for index in 0 ..< query.resultCount {
            guard let item = query.result(at: index) as? NSMetadataItem,
                  let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL
            else {
                continue
            }
            entries.append(BoardIndexEntry(
                rootURL: url.standardizedFileURL,
                modified: item.value(forAttribute: NSMetadataItemFSContentChangeDateKey) as? Date,
                download: Self.downloadState(of: item),
                location: .icloud
            ))
        }
        return entries
    }

    private static func downloadState(of item: NSMetadataItem) -> BoardDownloadState {
        let status = item.value(forAttribute: NSMetadataUbiquitousItemDownloadingStatusKey) as? String
        let isDownloading = item.value(forAttribute: NSMetadataUbiquitousItemIsDownloadingKey) as? Bool ?? false
        let percent = item.value(forAttribute: NSMetadataUbiquitousItemPercentDownloadedKey) as? Double

        if isDownloading {
            return .downloading(fraction: percent.map { $0 / 100 })
        }
        switch status {
        case NSMetadataUbiquitousItemDownloadingStatusCurrent,
             NSMetadataUbiquitousItemDownloadingStatusDownloaded:
            return .current
        case NSMetadataUbiquitousItemDownloadingStatusNotDownloaded:
            return .notDownloaded
        default:
            return .unknown
        }
    }

    // MARK: - Scanning

    /// Where the cloud half of one scan pass comes from. The device half never varies — it is always
    /// an enumeration of `deviceRoot` — so only this side needs saying.
    private enum CloudScanSource: Sendable {
        /// No container. The pass covers the device home alone.
        case absent

        /// The metadata query's answer, already flattened on the main actor.
        case query([BoardIndexEntry])

        /// A plain directory standing in for the container (`LANEWORK_LOCAL_ROOT`), enumerated off-main
        /// with the device root so one pass makes one hop.
        case directory(URL)
    }

    /// One pass: merge both homes, gate, request what is missing, summarize, land.
    ///
    /// - Parameter force: run even when the merged answer is identical to the last one. Notifications
    ///   do not set it, because `NSMetadataQueryDidUpdate` fires on upload and download *progress* as
    ///   well as on real changes and a repeat scan of an unchanged container is pure churn — a
    ///   directory walk and a file read per board. An explicit `refresh()` does set it: a
    ///   pull-to-refresh that provably does nothing is worse than a wasted walk.
    ///
    /// The gate runs off-main, against a snapshot of `lastEntries` taken here, because the merge it
    /// gates on is not knowable until the device root has been walked and that walk does not belong on
    /// the main actor. Two passes racing past a stale snapshot costs one redundant scan and cannot
    /// land out of order — the generation guard in `land` is what makes that true.
    private func scan(cloud: CloudScanSource, force: Bool) {
        scanGeneration += 1
        let generation = scanGeneration
        let previous = lastEntries
        let device = deviceRoot
        isScanning = true

        Task.detached(priority: .userInitiated) { [weak self] in
            var entries: [BoardIndexEntry]
            switch cloud {
            case .absent:
                entries = []
            case let .query(reported):
                entries = reported
            case let .directory(root):
                entries = Self.enumerateBoards(under: root, location: .icloud)
            }
            if let device {
                entries.append(contentsOf: Self.enumerateBoards(under: device, location: .local))
            }

            guard force || entries != previous else {
                await self?.scanFoundNoChange(generation: generation)
                return
            }

            // The one write this whole path makes: a board that is in the cloud and not here is asked
            // for. Requested off-main with the scan because `startDownloadingUbiquitousItem` talks to
            // the daemon, and requested on every pass because the daemon drops requests under memory
            // pressure and a repeat is free.
            for entry in entries where entry.download == .notDownloaded {
                try? FileManager.default.startDownloadingUbiquitousItem(at: entry.rootURL)
            }
            let summaries = entries.map(BoardSummaryScanner.scan).sorted(by: Self.displayOrder)
            await self?.land(summaries, entries: entries, generation: generation)
        }
    }

    private func land(_ summaries: [BoardSummary], entries: [BoardIndexEntry], generation: Int) {
        guard generation == scanGeneration else { return }
        lastEntries = entries
        boards = summaries
        isScanning = false
        phase = .ready

        // Every open session hears about it: the metadata query is also the only signal a board's
        // *contents* changed remotely, and a session waiting on materialization is waiting on
        // exactly this notification.
        let live = Set(summaries.map(\.rootURL))
        for (root, session) in sessions where live.contains(root) {
            session.containerDidUpdate()
        }
    }

    /// The gated pass's only effect. `phase` is deliberately untouched: a gate can only fire against a
    /// non-`nil` `lastEntries`, which means a previous pass already landed and already said `.ready`.
    private func scanFoundNoChange(generation: Int) {
        guard generation == scanGeneration else { return }
        isScanning = false
    }

    /// Title order, case- and diacritic-insensitive, tie-broken by path. Total and stable, so a
    /// refresh never reshuffles rows that did not change — and so the two homes interleave by name
    /// rather than clumping by where the merge happened to put them.
    private nonisolated static func displayOrder(_ lhs: BoardSummary, _ rhs: BoardSummary) -> Bool {
        switch lhs.title.localizedStandardCompare(rhs.title) {
        case .orderedAscending: true
        case .orderedDescending: false
        case .orderedSame: lhs.rootURL.path < rhs.rootURL.path
        }
    }

    /// `.kanban` directories directly inside `root`, tagged with the home they were found in. The name
    /// gate matches the metadata query's predicate exactly — an extension-less board folder loads fine
    /// but is not a *document*, and the index is a list of documents.
    private nonisolated static func enumerateBoards(under root: URL, location: BoardLocation) -> [BoardIndexEntry] {
        let contents = (try? FileManager.default.contentsOfDirectory(
            at: root,
            includingPropertiesForKeys: [.isDirectoryKey, .contentModificationDateKey],
            options: [.skipsHiddenFiles]
        )) ?? []

        return contents.compactMap { url in
            guard url.pathExtension == "kanban",
                  let values = try? url.resourceValues(forKeys: [.isDirectoryKey, .contentModificationDateKey]),
                  values.isDirectory == true
            else {
                return nil
            }
            return BoardIndexEntry(
                rootURL: url.standardizedFileURL,
                modified: values.contentModificationDate,
                download: .unknown,
                location: location
            )
        }
    }

    // MARK: - Creating a board

    /// Creates an empty board and answers where it landed — the empty-phone bootstrap, and one of the
    /// two writes this store makes.
    ///
    /// **iCloud when there is iCloud, this phone otherwise.** There is no picker: a board the user can
    /// sync is always the better default, and the one case where that is not available is the one case
    /// where asking would be a question with a single answer. Moving it afterwards is what
    /// `relocateBoard(at:to:)` is for, and the create prompt says which home it is about to use.
    ///
    /// The folder name comes from the title, because on this platform the document name *is* what the
    /// user sees in Files.app (01-storage-format.md § Board naming — the app writes the title key and
    /// names the folder to match). A collision appends a counter rather than failing: two boards
    /// called "Work" is a thing a person may reasonably want.
    ///
    /// Coordinated as a write on the new package's URL, then a refresh — the query would report the
    /// new board on its own within a second, but a create that does not immediately show its result
    /// is a create that looks broken, and a board created on the device is reported by nothing at all.
    @discardableResult
    func createBoard(titled title: String) async -> Result<URL, BoardCreateFailure> {
        guard let documents = home?.documentsURL ?? deviceRoot else {
            return .failure(.noHome)
        }
        let trimmed = title.trimmingCharacters(in: .whitespacesAndNewlines)

        let outcome: Result<URL, BoardCreateFailure> = await Task.detached(priority: .userInitiated) {
            let rootURL = Self.availableBoardURL(for: trimmed, in: documents)
            let coordinated = CoordinatedFileAccess.write(itemAt: rootURL) { resolved -> Result<URL, BoardWriteError> in
                do throws(BoardWriteError) {
                    try BoardWriter.createBoard(at: resolved, title: trimmed.isEmpty ? nil : trimmed)
                    return .success(resolved)
                } catch {
                    return .failure(error)
                }
            }
            switch coordinated {
            case let .failure(failure):
                return .failure(.coordination(failure))
            case let .success(inner):
                return inner.mapError(BoardCreateFailure.write)
            }
        }.value

        switch outcome {
        case let .success(url):
            lastError = nil
            refresh()
            return .success(url.standardizedFileURL)
        case let .failure(failure):
            lastError = failure.description
            Self.logger.error("board create failed: \(failure.description, privacy: .public)")
            return .failure(failure)
        }
    }

    /// A free `<name>.kanban` under `documents`, for a board being named from a title.
    private nonisolated static func availableBoardURL(for title: String, in documents: URL) -> URL {
        availableBoardURL(named: sanitizedFolderName(title), in: documents)
    }

    /// A free `<base>.kanban` under `documents`. Blocking (it stats), so it runs with the write it is
    /// choosing a destination for — and it is shared by create and move so the two cannot disagree
    /// about what "already taken" means.
    private nonisolated static func availableBoardURL(named base: String, in documents: URL) -> URL {
        var candidate = documents.appendingPathComponent("\(base).kanban", isDirectory: true)
        var counter = 2
        while FileManager.default.fileExists(atPath: candidate.path) {
            candidate = documents.appendingPathComponent("\(base) \(counter).kanban", isDirectory: true)
            counter += 1
        }
        return candidate
    }

    /// Path separators and colons out (the two characters a file name cannot survive), leading dots
    /// out (a board must not be hidden from its own index), length capped well under the 255-byte
    /// limit so the `.kanban` suffix and a collision counter always fit.
    private nonisolated static func sanitizedFolderName(_ title: String) -> String {
        let stripped = title
            .components(separatedBy: CharacterSet(charactersIn: "/:\\"))
            .joined(separator: " ")
            .trimmingCharacters(in: .whitespacesAndNewlines)
        let unhidden = stripped.drop(while: { $0 == "." })
        let name = unhidden.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !name.isEmpty else { return "Board" }
        return String(name.prefix(120))
    }

    // MARK: - Moving a board between homes

    /// Moves one board to the other home and answers where it landed.
    ///
    /// **The only operation in the app that changes a board's identity.** A board *is* its root URL
    /// (`BoardSummary.id`, `BoardSession.rootURL`), so a move retires one identity and mints another;
    /// the cached session is closed and dropped first, and the settings sheet this is called from
    /// dismisses on success, because the alternative is a screen holding a URL that no longer names
    /// anything.
    ///
    /// **`setUbiquitous` is the move**, not `moveItem`. Dragging bytes across the ubiquity boundary
    /// with `FileManager.moveItem` produces a folder in the right place with the wrong status — still
    /// ubiquitous, or never ubiquitous — and the daemon is then entitled to reconcile it away. The one
    /// API that transfers ubiquity along with the bytes is this one, and it is off-main because it
    /// talks to the daemon for as long as that takes.
    ///
    /// Evacuating iCloud asks for every byte at once, so the materialization sweep runs first — the
    /// same sweep a load runs, which also *requests* whatever is missing. A board that is not all here
    /// yet is a "try again in a moment", not a dead end.
    @discardableResult
    func relocateBoard(at rootURL: URL, to destination: BoardLocation) async -> Result<URL, BoardRelocateFailure> {
        let source = rootURL.standardizedFileURL

        guard let deviceRoot else { return .failure(.noHome) }
        let destinationRoot: URL
        switch destination {
        case .icloud:
            guard let documents = home?.documentsURL else { return .failure(.noCloudHome) }
            destinationRoot = documents
        case .local:
            destinationRoot = deviceRoot
        }

        guard source.deletingLastPathComponent().standardizedFileURL != destinationRoot.standardizedFileURL else {
            return .failure(.alreadyThere)
        }

        forgetSession(forBoardAt: source)

        let base = source.deletingPathExtension().lastPathComponent
        // Only a real container has ubiquity to transfer. Under `LANEWORK_LOCAL_ROOT` both homes are
        // ordinary directories, so the move is an ordinary move — which is also the path the UI tests
        // exercise, and the reason they can exercise it without an iCloud account.
        let isUbiquitous = home?.origin == .ubiquityContainer

        let outcome: Result<URL, BoardRelocateFailure> = await Task.detached(priority: .userInitiated) {
            let target = Self.availableBoardURL(named: base, in: destinationRoot)

            guard isUbiquitous else {
                let coordinated = CoordinatedFileAccess.write(itemAt: source) { resolved -> Result<URL, BoardRelocateFailure> in
                    do {
                        try FileManager.default.moveItem(at: resolved, to: target)
                        return .success(target)
                    } catch {
                        return .failure(.move(message: error.localizedDescription))
                    }
                }
                switch coordinated {
                case let .failure(failure): return .failure(.coordination(failure))
                case let .success(inner): return inner
                }
            }

            if destination == .local {
                let progress = PackageMaterialization.sweep(packageAt: source)
                guard progress.isComplete else { return .failure(.stillDownloading) }
            }

            do {
                try FileManager.default.setUbiquitous(
                    destination == .icloud,
                    itemAt: source,
                    destinationURL: target
                )
                return .success(target)
            } catch let error as CocoaError where error.code == .ubiquitousFileUnavailable {
                // The sweep said everything was here and the daemon disagreed — a race with an
                // eviction, or a file that went dataless between the walk and the move. Same sentence
                // either way, because the same thing fixes it: wait, then ask again.
                return .failure(.stillDownloading)
            } catch {
                return .failure(.ubiquity(message: error.localizedDescription))
            }
        }.value

        switch outcome {
        case let .success(url):
            lastError = nil
            refresh()
            return .success(url.standardizedFileURL)
        case let .failure(failure):
            lastError = failure.description
            Self.logger.error("board move failed: \(failure.description, privacy: .public)")
            return .failure(failure)
        }
    }

    // MARK: - Sessions

    /// The session for one board, minted on first ask and kept.
    ///
    /// Returned unopened: a view calls `open()` when it appears, which is what starts the
    /// materialization sweep. Two callers asking for the same root get the same object, so a
    /// navigation stack that holds a board list and a lane screen shares one snapshot.
    func session(forBoardAt rootURL: URL) -> BoardSession {
        let key = rootURL.standardizedFileURL
        if let existing = sessions[key] { return existing }
        let session = BoardSession(rootURL: key)
        sessions[key] = session
        return session
    }

    /// Drops a cached session and stops its retry timer. Call when a board's screen is gone for good —
    /// or when the board's URL is about to stop naming it, which is what a move does; keeping it
    /// otherwise costs one snapshot's memory, which is why nothing calls this automatically.
    func forgetSession(forBoardAt rootURL: URL) {
        let key = rootURL.standardizedFileURL
        sessions[key]?.close()
        sessions[key] = nil
    }
}

/// Why a board create did not happen.
enum BoardCreateFailure: Error, Sendable, Equatable, CustomStringConvertible {
    /// Neither home has resolved yet — the create was asked for before `start()` finished, which is
    /// the only window in which the app has nowhere at all to put a board.
    case noHome
    case coordination(CoordinationFailure)
    case write(BoardWriteError)

    var description: String {
        switch self {
        case .noHome: "there is nowhere to put a board yet"
        case let .coordination(failure): failure.description
        case let .write(error): error.description
        }
    }
}

/// Why a board did not move. Same shape as `BoardCreateFailure`: a closed set, each case a sentence
/// the settings sheet can show as-is.
enum BoardRelocateFailure: Error, Sendable, Equatable, CustomStringConvertible {
    /// The device home has not resolved — before `start()` finished, and nowhere else.
    case noHome

    /// iCloud was asked for as a destination and there is none.
    case noCloudHome

    /// The board is already in the home it was asked to move to.
    case alreadyThere

    /// Bytes are still coming down from iCloud, so there is nothing here to move out.
    case stillDownloading

    case coordination(CoordinationFailure)

    /// A plain move refused — the DEBUG stand-in path.
    case move(message: String)

    /// `setUbiquitous` refused for a reason other than missing bytes.
    case ubiquity(message: String)

    var description: String {
        switch self {
        case .noHome: "there is nowhere to move the board to yet"
        case .noCloudHome: "iCloud Drive is not available"
        case .alreadyThere: "the board is already stored there"
        case .stillDownloading: "still downloading from iCloud — try again once it finishes"
        case let .coordination(failure): failure.description
        case let .move(message): message
        case let .ubiquity(message): message
        }
    }
}
