import Observation
import SwiftUI

// MARK: - The window's confirmation host

/// The board window's purge alert, as a piece of window-local state (03-board-ui.md § Trash).
///
/// **It exists because a menu item cannot present anything.** File ▸ Delete (landing on a trash
/// selection) and Empty Trash… live in the menu bar, the trash row's context menu carries a twin of
/// the first, and all three must raise *the same* alert on *the window in front* — so the request
/// travels through the focus system exactly as `BoardInfoPresentation` does, and the alert itself is
/// hosted once by `BoardView`.
///
/// `@State` in `BoardWindowHost`, therefore one per window and dying with it: a half-answered
/// confirmation is not something to carry across a window's life.
@MainActor
@Observable
final class TrashConfirmations {

    /// The alert waiting to be answered, or `nil` when none is.
    ///
    /// The **phrasing is captured when the request is made**, not recomputed at render time: the
    /// user is being asked about the trash as it was when they invoked the command, and a foreign
    /// reload landing mid-alert must not silently change the sentence they are reading. The *action*
    /// re-resolves against the current snapshot when it runs, so a confirmed purge never acts on a
    /// card that has since gone — the writer treats an absent folder as success.
    private(set) var pending: Pending?

    struct Pending: Identifiable, Equatable {
        let id = UUID()
        let prompt: TrashModel.PurgePrompt
        let action: Action

        /// What the confirmation is standing in front of. Two cases, because permanence is only
        /// reachable inside the trash now: the trash's own staged Delete, and Empty Trash (which names
        /// the whole container and re-derives its targets at the moment it runs).
        enum Action: Equatable {
            case deleteTrashEntries(Set<ItemID>)
            case emptyTrash
        }
    }

    /// **File ▸ Delete, staged by place** (04-interactions.md ▸ The map) — with the confirmation the
    /// trash side owes and the board side does not.
    ///
    /// A board selection goes straight through: a card and a lane alike move into the trash, and the
    /// move is recoverable (the trash itself, and native undo — 03-board-ui.md § Trash), so neither
    /// stands an alert. A **trash** selection is the permanent one, and it "confirms exactly where
    /// the loss is real": `purgeIsUnrecoverable` decides.
    ///
    /// The staging itself lives on the store (`BoardStore.deleteSelection`), so this is the alert and
    /// nothing else — the two can never disagree about which write a ⌘⌫ performs.
    func requestDelete(in store: BoardStore) {
        guard store.selection.container == .trash else {
            store.deleteSelection()
            return
        }
        requestTrashDelete(of: store.selection.ids, in: store)
    }

    /// The **permanent** half of that staging, aimed at an explicit set — the trash card's
    /// context-menu Delete (11-command-nexus.md ▸ Context menus' Trash cards row).
    ///
    /// Its own entry point because a context menu names its target by where it was invoked, not by
    /// what is selected: right-clicking a trash card while a *board* selection stands must purge the
    /// clicked card, and a path that re-read `selection` would resolve those ids in the wrong
    /// container and silently do nothing.
    ///
    /// Same alert, same rule: it "confirms exactly where the loss is real", so
    /// `purgeIsUnrecoverable` decides — and where it does not, the purge runs straight through, which
    /// is what a board that keeps history does for every permanent delete (delete-never-forgets).
    func requestTrashDelete(of ids: Set<ItemID>, in store: BoardStore) {
        guard store.purgeIsUnrecoverable else {
            store.deleteTrashEntries(ids)
            return
        }
        guard let prompt = TrashModel.purgePrompt(
            for: ids,
            snapshot: store.snapshot,
            unrecoverable: true
        ) else { return }
        pending = Pending(prompt: prompt, action: .deleteTrashEntries(ids))
    }

    /// Raises Empty Trash…'s alert. **Always** — it guards bulk scope rather than per-item
    /// recoverability, so no board skips it.
    func requestEmptyTrash(in store: BoardStore) {
        guard let prompt = TrashModel.emptyTrashPrompt(
            in: store.snapshot,
            unrecoverable: store.purgeIsUnrecoverable
        ) else { return }
        pending = Pending(prompt: prompt, action: .emptyTrash)
    }

    /// Runs the pending action and dismisses. Idempotent: an alert answered twice (the button, then
    /// the dismissal SwiftUI drives from the binding) acts once.
    func confirm(in store: BoardStore) {
        guard let pending else { return }
        self.pending = nil
        switch pending.action {
        case let .deleteTrashEntries(ids): store.deleteTrashEntries(ids)
        case .emptyTrash: store.emptyTrash()
        }
    }

    func cancel() {
        pending = nil
    }
}

/// The focused board window's confirmation host — beside `FocusedValues.boardStore` and
/// `.boardInfo`, and reached the same way by the same kinds of caller.
struct FocusedTrashConfirmationsKey: FocusedValueKey {
    typealias Value = TrashConfirmations
}

extension FocusedValues {
    var trashConfirmations: TrashConfirmations? {
        get { self[FocusedTrashConfirmationsKey.self] }
        set { self[FocusedTrashConfirmationsKey.self] = newValue }
    }
}

// MARK: - File ▸ Delete / Empty Trash…

/// The File menu's trash rows (11-command-nexus.md).
///
/// ### One Delete, staged by place
///
/// **Put Back is retired with the tombstone model** (04-interactions.md ▸ The map, resettled
/// 2026-07-28): "File ▸ Delete is the chord's only owner — no twin menu items, no shared-equivalent
/// routing". The ⌘⌫ chord therefore has exactly one owner, its validation is one predicate
/// (`TrashModel.canDelete`), and which write it performs is decided by the selection's *container*
/// inside the store rather than by AppKit picking whichever of two items happened to be enabled.
struct TrashCommands: View {

    @FocusedValue(\.boardStore) private var store
    @FocusedValue(\.trashConfirmations) private var confirmations

    var body: some View {
        Button("Delete") {
            guard let store, let confirmations else { return }
            confirmations.requestDelete(in: store)
        }
        .keyboardShortcut(.delete, modifiers: .command)
        .disabled(!canDelete || confirmations == nil)

        Button("Empty Trash…") {
            guard let store, let confirmations else { return }
            confirmations.requestEmptyTrash(in: store)
        }
        .keyboardShortcut(.delete, modifiers: [.shift, .command])
        .disabled(!canEmptyTrash)
    }

    /// A non-empty selection that still names something, on a board that accepts writes — both
    /// stagings at once, which is what having one item means.
    private var canDelete: Bool {
        guard let store, store.acceptsBoardMutations else { return false }
        return TrashModel.canDelete(selection: store.selection, in: store.snapshot)
    }

    private var canEmptyTrash: Bool {
        store?.canEmptyTrash == true
    }
}

extension BoardStore {

    /// **Trash shown and non-empty** (11-command-nexus.md's own scope for the Empty Trash… row) —
    /// where "non-empty" reads `.trash/` itself — **both kinds of entry**, and never the filtered
    /// view (03-board-ui.md § Trash: "menu validation's non-empty reads `.trash/`, not the filtered
    /// view", so a search that hides every trash row leaves the command enabled and its confirmation
    /// still names the true count, lane freight included).
    ///
    /// The visibility clause is 04-interactions.md's, stated for the whole column: "hidden, it is
    /// invisible to every gesture".
    ///
    /// On the store rather than private to the menu row so the validation can be pinned without a
    /// menu (`TrashModel`'s own reason for being a pure function of a snapshot).
    var canEmptyTrash: Bool {
        guard acceptsBoardMutations, transient.isTrashVisible else { return false }
        return !snapshot.trash.isEmpty || !snapshot.trashedLanes.isEmpty
    }
}

// MARK: - View ▸ Show Trash

/// View ▸ Show Trash — a checkmark toggle with **no default chord** (11-command-nexus.md).
///
/// ⇧⌘T is deliberately left to the system's Show Tab Bar: window tabbing stays enabled, so the chord
/// is the system's, and a user who wants one here assigns it through the remapping mechanism.
///
/// **One stable title with a checkmark state** — "Show Trash" stays "Show Trash" when checked, never
/// becomes "Hide Trash" (04-interactions.md ▸ Configurable bindings, since the title is the key a
/// custom binding is stored under).
///
/// Neither the read-only lock nor the focused-editor rule closes it: showing the trash is a view
/// change, not a mutation, and a locked board is exactly when a user wants to look at what is in
/// there.
struct ShowTrashCommand: View {

    @FocusedValue(\.boardStore) private var store

    var body: some View {
        Toggle("Show Trash", isOn: isVisible)
            .disabled(store == nil)
    }

    /// The toggle's binding — and the one place hiding the trash has a consequence beyond layout.
    ///
    /// **Hiding drops a trash selection** (04-interactions.md ▸ The trash: "hiding it clears a
    /// selection of trash cards — nothing invisible stays selected, so the toggle-off drops the
    /// selection rather than leave commands enabled against rows nobody can see"). A *board*
    /// selection is untouched — the board it names is still right there.
    ///
    /// The setter's body lives on the store (`BoardStore.setTrashVisible`) because the toolbar's
    /// Show Trash item is this same command with a different face (03-board-ui.md ▸ Toolbar: "toggle
    /// state matching the View menu checkmark"), and the consequence above has to be true of both.
    private var isVisible: Binding<Bool> {
        Binding(
            get: { store?.transient.isTrashVisible ?? false },
            set: { shown in store?.setTrashVisible(shown) }
        )
    }
}

extension BoardStore {

    /// Show/Hide Trash, with its one consequence — **the whole of the View-menu row's behaviour**,
    /// shared with the toolbar item that mirrors it.
    func setTrashVisible(_ shown: Bool) {
        // The re-divide is a *user-initiated structural change* — every lane compresses or relaxes
        // as the trash's one unit joins or leaves the division — so it animates in the structural
        // voice (03-board-ui.md § Motion; § Trash makes Show/Hide Trash "a re-divide trigger", a
        // lane add's behaviour exactly). It is also one of the few structural changes that never
        // touches disk, which is why it wears its own transaction here instead of arriving through
        // the reload seam like the rest.
        //
        // Reduce Motion read from AppKit rather than from `@Environment`: a menu command's content
        // is built outside any rendered hierarchy, where the environment's accessibility values are
        // not reliably populated (`Motion.prefersReducedMotion`) — and a toolbar item has no
        // environment at all.
        withAnimation(Motion.structural(reduced: Motion.prefersReducedMotion)) {
            transient.isTrashVisible = shown
            // Dropped inside the same transaction: the rows it pointed at are leaving under this
            // very animation, and a selection that cleared outside it would be the highlight easing
            // on its own — which 03 § Motion rules out ("the selection highlight rides whatever
            // transaction is active").
            if !shown, selection.container == .trash {
                clearSelection()
            }
        }
        // **"Toggling visibility is announced"** (10-accessibility.md ▸ Trash lane). A whole
        // container joins or leaves the accessibility tree here and nothing else marks it: the
        // VoiceOver cursor does not move, no focus is lost, and the re-divide every lane performs is
        // silent by nature. Announced from the store rather than from either caller for
        // `setTrashVisible`'s own reason — the View menu row and the toolbar item are one command
        // with two faces, and a consequence written at one of them would be missing from the other.
        //
        // Deliberately **not** on the reload's one-announcement-per-debounce ladder
        // (`BoardAnnouncer`): this is a view state the user just toggled, not a change to the board's
        // files, so there is no origin to classify and nothing for it to compete with.
        announce(AccessibilityPhrases.trashVisibility(shown: shown))
    }
}

// MARK: - The alert

extension View {

    /// Hosts the board window's purge alert — one surface for every path that asks for one.
    ///
    /// An **alert** rather than a confirmation dialog: this is a modal, destructive yes/no about
    /// named items, which is what macOS's alert is for, and what Finder puts in front of the same
    /// gesture.
    func trashPurgeAlert(store: BoardStore, confirmations: TrashConfirmations) -> some View {
        alert(
            confirmations.pending?.prompt.title ?? "",
            isPresented: Binding(
                get: { confirmations.pending != nil },
                // Any dismissal that is not the confirm button is a cancel — Escape, a click
                // outside, the sheet being torn down.
                set: { presented in if !presented { confirmations.cancel() } }
            ),
            presenting: confirmations.pending
        ) { pending in
            Button(pending.prompt.confirmTitle, role: .destructive) {
                confirmations.confirm(in: store)
            }
            Button("Cancel", role: .cancel) {
                confirmations.cancel()
            }
        } message: { pending in
            Text(pending.prompt.message)
        }
    }
}
