import AppKit
import SwiftUI
import os

// MARK: - Focused values

/// The frontmost board window's **identity**, published beside its store by `BoardWindowHost`.
///
/// `FocusedBoardStoreKey` answers "which board is in front"; this answers "which *window*", which is
/// a different question and the one File ▸ Duplicate has to ask: the flush that precedes a copy is
/// keyed on the window's session, not on the store (`AppModel.flushPendingWork(for:)`).
struct FocusedBoardWindowRefKey: FocusedValueKey {
    typealias Value = BoardWindowRef
}

/// The welcome window's selected recents row — File ▸ Reveal in Finder's welcome scope.
struct FocusedWelcomeSelectionKey: FocusedValueKey {
    typealias Value = WelcomeRow
}

extension FocusedValues {
    var boardWindowRef: BoardWindowRef? {
        get { self[FocusedBoardWindowRefKey.self] }
        set { self[FocusedBoardWindowRefKey.self] = newValue }
    }

    var welcomeSelection: WelcomeRow? {
        get { self[FocusedWelcomeSelectionKey.self] }
        set { self[FocusedWelcomeSelectionKey.self] = newValue }
    }
}

// MARK: - New Board

/// File ▸ New Board… (⌥⌘N) — the template chooser's entry point (11-command-nexus.md;
/// 09-templates.md).
///
/// **⌥⌘N, not ⌘N**: ⌘N is New *Card*, which is the command a board window user reaches for a hundred
/// times a day, so the rarer creation wears the modifier. Available everywhere — a new board needs
/// no board in front, and the welcome window's own button is this item's twin.
struct NewBoardCommand: View {

    let appModel: AppModel

    var body: some View {
        Button("New Board…") {
            appModel.showTemplateChooser()
        }
        .keyboardShortcut("n", modifiers: [.option, .command])
    }
}

// MARK: - Open Recent

/// File ▸ Open Recent ▸ (11-command-nexus.md: "Everywhere; reads the board registry").
///
/// The registry, rendered as a menu — same rows as the welcome list, through the same derivation, so
/// the two can never disagree about a board's name or about whether it can be opened. An
/// unavailable board is **listed and disabled** rather than hidden, which is the recents row's own
/// posture (02 § Graceful orphaning) applied to a menu: a board that has gone missing is information,
/// and a menu that quietly shortened itself would be the app forgetting on the user's behalf.
///
/// Clear Menu sits at the bottom, where Finder puts it. See `AppModel.clearRecents` for the
/// equivalence it rests on — the registry *is* this menu, so clearing the menu clears the registry.
struct OpenRecentMenu: View {

    let appModel: AppModel

    /// The failures are deliberately not joined in here: a menu item has no room for fail-fast's
    /// specifics, and a board that failed to open is still a board the user may want to try again.
    /// The failure's surface is the welcome row (02 § Launch and window lifecycle).
    private var rows: [WelcomeRow] {
        WelcomeRow.derive(recents: appModel.recents, failures: []).rows
    }

    var body: some View {
        let rows = self.rows

        Menu("Open Recent") {
            ForEach(rows) { row in
                Button(row.displayName) {
                    guard let url = row.url else { return }
                    appModel.openBoard(at: url)
                }
                .disabled(!row.canOpen)
            }

            if !rows.isEmpty {
                Divider()
            }

            Button("Clear Menu") {
                appModel.clearRecents()
            }
            .disabled(rows.isEmpty)
        }
    }
}

// MARK: - Duplicate

/// File ▸ Duplicate (⇧⌘S) — **the board**, never the selection (11-command-nexus.md, 03-board-ui.md
/// § Welcome screen & templates).
///
/// ### What it does, in the order 03 fixes
///
/// 1. **The flush first** — "The copy is preceded by the close flush ... so neither the tree nor the
///    copied history misses pending work". Not a *close*: 09-templates.md states the rule with its
///    exception attached ("sessions staying open"), and 03 is explicit that "the original stays open
///    too". `AppModel.flushPendingWork(for:)` is that step of the sequence, run on its own.
/// 2. **The copy** — `BoardDuplicator`, off the main actor so the spinner can spin, and cancellable:
///    the in-progress row carries Cancel, which cancels the copy task, and the walk removes its own
///    partial sibling on the way out ("a cancelled duplicate never happened").
/// 3. **The save panel, but only on a refusal** — "the silent Finder-style sibling is attempted
///    first; on a permission refusal a save panel opens pre-filled with the parent folder and the
///    'copy' name — the panel's grant is the sandbox's own answer, and it doubles as a
///    choose-another-location affordance" (03, settled). The board's bookmark grants its own subtree,
///    not its parent, so the sibling may simply be unwritable; that is a question about *where*, and
///    the panel is where the sandbox answers it.
/// 4. **The copy opens in its own board window** — "macOS Duplicate convention" — through the
///    ordinary open path, so it registers, bookmarks, and titles itself like any other board.
///
/// ### Three endings, and only one of them speaks
///
/// A copy that lands opens. A copy the user **cancelled** — the row's Cancel, or the save panel's —
/// says nothing at all: "cancelling the panel cancels the duplicate quietly (no banner — the user
/// declined, nothing failed)", and the row's Cancel is the same sentence about the same gesture.
/// Everything else — a full disk, a name already taken — is the ordinary one-shot banner
/// (02-architecture.md § Write-failure surfacing). `BoardDuplicator.Failure`'s three cases are those
/// three endings, switched exhaustively below so a fourth could not be forgotten.
///
/// ### Validation
///
/// Board window only, so a welcome-selected recent can never be duplicated by accident — 03 says it
/// "never acts on a welcome-selected recent", and scoping the item to the focused board window is
/// how that is enforced rather than remembered.
///
/// **Disabled under the read-only lock in every state** (03: "the flush can't run and the sibling
/// destination shares the board's fate"). It uses `acceptsBoardMutations`, which adds the
/// focused-inline-editor half of 04's rule to the lock 03 names — a deliberate reading rather than a
/// slip: an open title editor holds the one pending change no flush can reach, and a duplicate taken
/// mid-rename would be a fork missing the edit the user is in the middle of making.
struct DuplicateBoardCommand: View {

    let appModel: AppModel

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.boardWindowRef) private var ref

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

    var body: some View {
        Button("Duplicate") {
            duplicate()
        }
        .keyboardShortcut("s", modifiers: [.shift, .command])
        .disabled(!canDuplicate)
    }

    private var canDuplicate: Bool {
        guard let store, ref != nil else { return false }
        return store.acceptsBoardMutations
    }

    private func duplicate() {
        guard canDuplicate, let store, let ref else { return }
        let name = AppModel.displayName(of: store)
        let source = store.rootURL

        Task { @MainActor in
            let cancellation = BoardCopyCancellation<URL>()
            // The in-progress row 02 § The banner surface names for "big-board Duplicate": info
            // tone, pinned, cleared on completion, swapped for the error row on failure — and
            // carrying Cancel, which 02 promises on copy-shaped work ("remove the partial copy,
            // nothing lost") and 03 spends on this command by name.
            let operation = store.banners.beginOperation(
                label: "Duplicating '\(name)'…",
                cancel: { cancellation.cancel() }
            )
            defer { store.banners.endOperation(operation) }

            await appModel.flushPendingWork(for: ref)

            // 1. The silent Finder-style sibling.
            var outcome = await copy(source, titled: name, into: nil, cancellation: cancellation)

            // 2. The save panel, and only where 03 puts it: a *permission* refusal of that sibling.
            if case let .failure(.refused(refusal)) = outcome {
                Self.logger.notice("duplicate refused: \(refusal.description, privacy: .public)")
                guard let chosen = Self.chooseDestination(for: source) else {
                    // The user declined. Nothing failed, so nothing is said.
                    return
                }
                outcome = await copy(source, titled: name, into: chosen, cancellation: cancellation)
            }

            switch outcome {
            case let .success(copy):
                appModel.openBoard(at: copy)
            case .failure(.cancelled):
                // The walk removed its own partial sibling; the row leaves with the `defer`.
                Self.logger.notice("duplicate cancelled — the partial copy was removed")
            // `.refused` cannot reach here (the panel path either returns above or asks a
            // destination that never refuses again), but it carries a real failure, so if it ever
            // did, it would be said out loud rather than swallowed.
            case let .failure(.failed(error)), let .failure(.refused(error)):
                Self.logger.error("duplicate failed: \(error.description, privacy: .public)")
                store.banners.post(error)
            }
        }
    }

    /// One attempt at the copy — off the main actor, wired to the banner row's Cancel.
    ///
    /// `Task.detached` rather than a child task, for both halves of the reason: the copy is real I/O
    /// on a board that may carry a large `.git`, and a spinner drawn by a blocked main thread is a
    /// still picture (see `BoardDuplicator` for why running it off the actor is safe); and a detached
    /// task's cancellation is *only* the Cancel button's, never something inherited from whatever
    /// else the enclosing task is doing.
    ///
    /// `destination` is `nil` for the Finder-style sibling and the panel's answer otherwise — the two
    /// `BoardDuplicator` entry points, which differ only in who chose the location and therefore in
    /// whether a permission failure is a question or an answer.
    private func copy(
        _ source: URL,
        titled name: String,
        into destination: URL?,
        cancellation: BoardCopyCancellation<URL>
    ) async -> Result<URL, BoardDuplicator.Failure> {
        // Cancelled during the flush: the copy never starts, rather than starting and being told to
        // stop — the same outcome, reached without making a folder to delete.
        guard !cancellation.isCancelled else { return .failure(.cancelled) }

        let task = Task.detached(priority: .userInitiated) {
            if let destination {
                return try BoardDuplicator.duplicate(boardAt: source, titled: name, into: destination)
            }
            return try BoardDuplicator.duplicate(boardAt: source, titled: name)
        }
        cancellation.attach(task)

        do {
            return .success(try await task.value)
        } catch let failure as BoardDuplicator.Failure {
            return .failure(failure)
        } catch {
            return .failure(.failed(BoardWriteError(
                operation: .duplicateBoard(title: name),
                path: source.path,
                reason: .io(message: error.localizedDescription)
            )))
        }
    }

    /// The save panel a refusal hands the question to (03, settled) — "pre-filled with the parent
    /// folder and the 'copy' name".
    ///
    /// Both pre-fills are the sibling the app just failed to write, so the panel opens showing
    /// exactly what would have happened silently, and one Return makes it happen. Whatever the user
    /// changes is then honored verbatim (`BoardDuplicator.duplicate(boardAt:titled:into:)`): the
    /// panel is a location grant *and* a choose-another-location affordance, and second-guessing the
    /// name it returns would break the second half.
    ///
    /// `nil` is the user declining, which this command answers with silence. The panel is modal,
    /// like the template chooser's — the in-progress row stays up behind it, because the duplicate
    /// genuinely is still in progress.
    private static func chooseDestination(for source: URL) -> URL? {
        let panel = NSSavePanel()
        panel.directoryURL = source.deletingLastPathComponent()
        panel.nameFieldStringValue = BoardDuplicator.copyDestination(for: source).lastPathComponent
        panel.canCreateDirectories = true
        panel.isExtensionHidden = false
        panel.allowsOtherFileTypes = true
        panel.prompt = "Duplicate"
        panel.message = "Choose where to keep the duplicate."

        guard panel.runModal() == .OK, let url = panel.url else { return nil }
        return url
    }
}

// MARK: - Save as Template

/// File ▸ Save as Template — the board, into the user templates store (11-command-nexus.md: "Board
/// window; 09-templates.md").
///
/// ### It is Duplicate's sequence with a different destination
///
/// 09-templates.md ▸ Save as Template states the rule and names Duplicate in the same breath: "**The
/// copy is preceded by the close flush** … so the template never misses the last keystrokes; the same
/// rule covers File ▸ Duplicate". So the shape here is `DuplicateBoardCommand`'s, step for step —
/// flush, then a cancellable copy off the main actor under an in-progress row — and the differences
/// are all in the engine (`TemplateEngine.saveAsTemplate(boardAt:titled:into:)`): the destination is
/// the app-side template store rather than a sibling, `.git` and `.trash/` are dropped rather than forked, a
/// collision auto-renames rather than failing, and a `template:` key lands on the copy.
///
/// **No save panel, ever.** The store is the app's own container — "friction-free sandbox writes, no
/// location ceremony" (09 ▸ Storage) — so there is no location question to ask, and therefore no
/// `.refused` outcome to answer: a permission failure writing inside our own container is an
/// ordinary failure with an ordinary banner.
///
/// ### The ending that speaks is the quiet one
///
/// A duplicate opens in a window, so it announces itself. A template lands in a folder nobody is
/// looking at, so this posts a **passive signpost** — the info tone's calm half (02-architecture.md
/// § The banner surface: "Passive info rows rank last and may collapse — calm by design"). It names
/// the template rather than only the board, because that is where a Finder-style auto-rename becomes
/// visible: "Saved 'Roadmap' as the template 'Roadmap 2'" is the only place the user is told which
/// one they just made. A cancel says nothing (the partial is gone, the duplicate rule verbatim), and
/// a failure is the ordinary one-shot banner.
///
/// ### Validation is Duplicate's, minus 09's one carve-out
///
/// Board window only, and disabled under the read-only lock — with the exception 09 spells out and
/// 02-architecture.md ▸ Live-reload resilience scopes: **under the unwritable-location lock alone it
/// stays live**, because it "reads the board and writes the app-side store" (archiving the
/// read-only DMG board being inspected is a legitimate errand), *unless* an open Edit or raw-source
/// session holds unsaved content — content that lock's suspended saves cannot flush, and which the
/// template would therefore silently miss. The other two locks disable it outright: a vanished root
/// has nothing to copy, and a board whose last reload failed is a tree whose state is least known.
///
/// The focused-editor half of `acceptsBoardMutations` is kept in every branch, for
/// `DuplicateBoardCommand`'s reason: an open inline title editor holds the one pending change no
/// flush can reach, and a template taken mid-rename would miss the edit being made.
struct SaveAsTemplateCommand: View {

    let appModel: AppModel

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.boardWindowRef) private var ref

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

    var body: some View {
        Button("Save as Template") {
            save()
        }
        .disabled(!canSave)
    }

    private var canSave: Bool {
        guard let store, let ref else { return false }
        return Self.allowsSave(
            lock: store.readOnlyLock,
            isEditingInline: store.isEditingInline,
            hasUnsavedCardContent: appModel.hasUnsavedCardContent(for: ref)
        )
    }

    /// The item's validation as a pure function of the three facts it turns on — extracted from
    /// `canSave` so the carve-out can be tested at every combination rather than only through a
    /// menu.
    ///
    /// The carve-out itself is 02-architecture.md ▸ Live-reload resilience, settled: under the
    /// **unwritable-location lock alone** this stays live (copy-out is a read — archiving the
    /// read-only DMG board being inspected is a legitimate errand), and it "gates on the hazard
    /// itself, open sessions, not on lock provenance" — so an Edit or raw-source session holding
    /// unsaved content disables it, whether the lock arrived at open or from the symmetric probe
    /// mid-session, and nothing here asks which. The other two locks disable it outright.
    nonisolated static func allowsSave(
        lock: ReadOnlyLockReason?,
        isEditingInline: Bool,
        hasUnsavedCardContent: Bool
    ) -> Bool {
        guard !isEditingInline else { return false }
        switch lock {
        case .none:
            return true
        case .unwritableLocation:
            return !hasUnsavedCardContent
        case .vanishedRoot, .bracketedReloadFailed:
            return false
        }
    }

    private func save() {
        guard canSave, let store, let ref else { return }
        let name = AppModel.displayName(of: store)
        let source = store.rootURL

        Task { @MainActor in
            let cancellation = BoardCopyCancellation<URL>()
            // Copy-shaped work, so the row carries Cancel — "remove the partial copy, nothing lost"
            // (02 § The banner surface), which the engine honors by removing the partial store entry.
            let operation = store.banners.beginOperation(
                label: "Saving '\(name)' as a template…",
                cancel: { cancellation.cancel() }
            )
            defer { store.banners.endOperation(operation) }

            await appModel.flushPendingWork(for: ref)

            // Cancelled during the flush: the copy never starts, rather than starting and being told
            // to stop (`DuplicateBoardCommand.copy(_:titled:into:cancellation:)`'s guard, verbatim).
            guard !cancellation.isCancelled else { return }

            // Detached, for the two halves of Duplicate's reason: the copy is real I/O on a board
            // that may carry a large `.git` — a spinner drawn by a blocked main thread is a still
            // picture — and a detached task's cancellation is only ever this row's Cancel.
            let task = Task.detached(priority: .userInitiated) {
                try TemplateEngine.saveAsTemplate(boardAt: source, titled: name)
            }
            cancellation.attach(task)

            do {
                let landed = try await task.value
                store.banners.postSignpost(
                    Self.savedMessage(board: name, template: TemplateEngine.documentName(of: landed))
                )
            } catch TemplateEngine.Failure.cancelled {
                Self.logger.notice("save as template cancelled — the partial template was removed")
            } catch let TemplateEngine.Failure.failed(error) {
                Self.logger.error("save as template failed: \(error.description, privacy: .public)")
                store.banners.post(error)
            } catch {
                let write = BoardWriteError(
                    operation: .saveAsTemplate(title: name),
                    path: source.path,
                    reason: .io(message: error.localizedDescription)
                )
                Self.logger.error("save as template failed: \(write.description, privacy: .public)")
                store.banners.post(write)
            }
        }
    }

    /// The signpost's line. The template is named only when the store's collision ladder gave it a
    /// different one — saying "Saved 'Roadmap' as the template 'Roadmap'" would be noise, while
    /// leaving the rename unsaid would hide the one thing about this save the user could not predict.
    static func savedMessage(board: String, template: String) -> String {
        board == template
            ? "Saved '\(board)' as a template"
            : "Saved '\(board)' as the template '\(template)'"
    }
}

// MARK: - Share

/// File ▸ Share… — the board staged into a `.zip` and handed to `NSSharingServicePicker`
/// (11-command-nexus.md; design ruling 2026-08-09, card 72691b11 "create a share sheet target for
/// board").
///
/// ### What it shares, and why a zip
///
/// A board is a folder tree, and no share destination (Mail, Messages, AirDrop) knows how to carry
/// one as an item the way it carries a file — so this stages the board into a single `.zip`, named
/// `"<Board Title>.zip"`, and hands the picker that one URL (`BoardShareStager`). The content rule
/// is the ruling's own: **`.git` is the sole exclusion** — inert history a recipient has no use for
/// and should not receive unasked — and **everything else rides along verbatim: attachments,
/// comments, and `.trash/` included**, "a faithful copy" in the ruling's own words, echoing
/// `BoardDuplicator`'s posture rather than `TemplateEngine`'s narrower one. The trash inclusion is
/// flagged for owner review in the card's DECISIONS comment.
///
/// ### The sequence is Duplicate's, once more, with a picker where the open used to be
///
/// 1. **The flush first** — `AppModel.flushPendingWork(for:)`, `DuplicateBoardCommand`'s own step:
///    "stage from a flushed state … never share a half-written buffer" is the ruling's phrasing for
///    exactly what pending-work-landed-on-disk means.
/// 2. **The stage** — `BoardShareStager.stage(boardAt:titled:)`, off the main actor so the banner's
///    in-progress row can spin, cancellable the same way (`BoardCopyCancellation`).
/// 3. **The picker** — `BoardSharePresentation`, anchored to the window that was key when Share was
///    pressed (captured weakly before the flush's `await`, so a window closed mid-stage does not
///    keep it alive, and re-resolved to the current key window if it went away — `PrintCoordinator
///    .keyWindow()`'s reasoning, stretched across the async gap staging needs that printing does
///    not). Presentation owns the staged files from there — it is the one thing that knows when the
///    picker's interaction has actually finished — and it is what removes them.
///
/// ### Validation
///
/// **Simpler than Duplicate's or Save as Template's**, by the ruling's own words ("enabled when a
/// board window is focused") — and defensibly so: unlike either sibling, this never writes a byte
/// into the board's own tree, so the read-only lock's reason for existing (protecting board writes
/// a sandbox refusal or a vanished root would refuse) does not apply — `PrintCommand`'s own "a
/// print is a read" posture, applied to a copy that leaves rather than one that prints. **The one
/// carve-out kept is the focused-inline-editor half**, not the whole of `acceptsBoardMutations`: an
/// open rename or new-card placeholder holds the one pending change no flush can reach
/// (`DuplicateBoardCommand`'s own note), and sharing mid-rename would zip a title the user is still
/// typing. Flagged for owner review — the ruling's literal wording names only "board window
/// focused", not this carve-out.
struct ShareBoardCommand: View {

    let appModel: AppModel

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.boardWindowRef) private var ref

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

    var body: some View {
        Button("Share…") {
            share()
        }
        .disabled(!canShare)
    }

    /// The row's whole decision, as a pure function of the three facts it turns on
    /// (`SaveAsTemplateCommand.allowsSave`'s reason for extracting validation from the view: a rule
    /// reachable only through a menu is a rule nobody tests).
    static func isEnabled(hasStore: Bool, hasRef: Bool, isEditingInline: Bool) -> Bool {
        hasStore && hasRef && !isEditingInline
    }

    private var canShare: Bool {
        Self.isEnabled(
            hasStore: store != nil,
            hasRef: ref != nil,
            isEditingInline: store?.isEditingInline == true
        )
    }

    private func share() {
        guard canShare, let store, let ref else { return }
        let name = AppModel.displayName(of: store)
        let source = store.rootURL

        Task { @MainActor in
            // Weak: the window this share was invoked from must not be kept alive by this task
            // across the flush's `await` just so the picker can anchor to it later.
            weak var invokedWindow = NSApp.keyWindow ?? NSApp.mainWindow

            let cancellation = BoardCopyCancellation<BoardShareStager.StagedArchive>()
            let operation = store.banners.beginOperation(
                label: "Preparing '\(name)' to share…",
                cancel: { cancellation.cancel() }
            )
            defer { store.banners.endOperation(operation) }

            await appModel.flushPendingWork(for: ref)

            // Cancelled during the flush: staging never starts (`SaveAsTemplateCommand.save()`'s
            // own guard, verbatim).
            guard !cancellation.isCancelled else { return }

            // Detached, `DuplicateBoardCommand`'s two-part reason: real I/O (a board's trash and
            // attachments can be real bytes, and `ditto` zipping them is not instant) that must not
            // block the banner's spinner, and a detached task's cancellation is only ever this
            // row's Cancel.
            let task = Task.detached(priority: .userInitiated) {
                try BoardShareStager.stage(boardAt: source, titled: name)
            }
            cancellation.attach(task)

            do {
                let archive = try await task.value
                let anchorWindow = invokedWindow ?? NSApp.keyWindow ?? NSApp.mainWindow
                BoardSharePresentation(archive: archive).present(anchorWindow: anchorWindow)
            } catch let failure as BoardShareStager.Failure {
                switch failure {
                case .cancelled:
                    // The walk removed its own partial staging directory; the row leaves with the
                    // `defer`.
                    Self.logger.notice("share cancelled — the staged copy was removed")
                case let .failed(error):
                    Self.logger.error("share failed: \(error.description, privacy: .public)")
                    store.banners.post(error)
                }
            } catch {
                let write = BoardWriteError(
                    operation: .shareBoard(title: name),
                    path: source.path,
                    reason: .io(message: error.localizedDescription)
                )
                Self.logger.error("share failed: \(write.description, privacy: .public)")
                store.banners.post(write)
            }
        }
    }
}

/// The Cancel button's end of a running board copy — File ▸ Duplicate's, File ▸ Save as Template's
/// and File ▸ Share…'s alike: the one piece of state the banner row's `cancel` closure and the copy
/// task have to share.
///
/// **A main-actor box rather than a lock**, because there is nothing here to race over: the row's
/// `cancel` is `@MainActor @Sendable`, and the task is created and attached on the same actor. The
/// walk itself reads no shared state at all — it reads its own `Task.isCancelled`, which `cancel()`
/// sets by cancelling the task — so this type exists only to close the window between the row
/// appearing and the copy task existing. A Cancel pressed during the flush must not be forgotten by
/// the task that starts after it, which is what `isCancelled` is for.
///
/// **Generic over the task's success type** (`URL` for Duplicate and Save as Template,
/// `BoardShareStager.StagedArchive` for Share) since it joined a third caller whose detached task
/// answers a different value — the cancellation bookkeeping is identical either way and does not
/// care what the copy produced.
@MainActor
private final class BoardCopyCancellation<Success: Sendable> {

    private var task: Task<Success, any Error>?
    private(set) var isCancelled = false

    func attach(_ task: Task<Success, any Error>) {
        self.task = task
        if isCancelled { task.cancel() }
    }

    func cancel() {
        isCancelled = true
        task?.cancel()
    }
}

// MARK: - Reveal in Finder

/// File ▸ Reveal in Finder — the welcome and board scopes (11-command-nexus.md: "Board window: the
/// selection's folder(s), or the board root with nothing selected; … welcome: the selected recent's
/// folder (disabled on unavailable rows) — the context-menu entry's required twin").
///
/// It is here because the welcome row's context menu is: 11 files the menu-bar item as that entry's
/// *required* twin, so shipping one without the other would leave the context menu as the only path
/// to a command — the thing 04's contract forbids.
///
/// **The board scope reveals either side of the trash boundary and ignores every lock.** Reveal "is
/// not edit-shaped and stays enabled on trash selections" (04 ▸ The trash), and inspection is a
/// read, so neither the read-only lock nor the focused-editor rule applies — the same posture the
/// trash row's own Reveal takes. A selection whose ids resolve to no folders (one the next reload
/// will drop) disables rather than falling back to the root: revealing the wrong thing is worse
/// than nothing, and only a genuinely empty selection means "the board".
///
/// **The card-window scope is the third branch**, and it is the one the attachment row's context
/// menu twins (11-command-nexus.md ▸ Context menus): "card window: the card's folder — the selected
/// attachment's file instead when the attachments section is focused". The rule itself is
/// `CardAttachments.revealURLs`, so the menu row and the row's own Reveal cannot disagree about what
/// "the selected attachment" means.
struct RevealInFinderCommand: View {

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.cardAttachments) private var attachments
    @FocusedValue(\.welcomeSelection) private var selection

    var body: some View {
        Button("Reveal in Finder") {
            NSWorkspace.shared.activateFileViewerSelecting(urls)
        }
        .disabled(urls.isEmpty)
    }

    /// What the item would reveal, and therefore whether it is enabled — one answer for both, the
    /// codebase's usual shape. The board in front wins; the card-window branch stands when a card
    /// window is; the welcome branch stands when neither is.
    private var urls: [URL] {
        if let store {
            let ids = store.selection.ids
            guard !ids.isEmpty else { return [store.rootURL] }
            return ItemPath.resolve(ids, in: store.selection.container, snapshot: store.snapshot)
                .map { $0.folder(under: store.rootURL) }
        }
        if let attachments {
            return CardAttachments.revealURLs(
                cardFolder: attachments.cardFolder,
                selectedURL: attachments.selectedURL,
                isSectionFocused: attachments.isFocused
            )
        }
        guard let selection, selection.canReveal, let url = selection.url else { return [] }
        return [url]
    }
}
