import Foundation

// MARK: - TrashModel

/// What is left of the trash as a *model* once the trash became a folder — 03-board-ui.md § Trash's
/// **materialized** container (resettled 2026-07-28).
///
/// ### Almost nothing, and that is the point of the pivot
///
/// The tombstone model needed a whole derivation layer: an entry type, an absolute ancestor walk, a
/// returning-card count, a deterministic `deleted`-timestamp sort, and a paths function that had to
/// restate which items were addressable. All of it is gone. A trashed card is "an ordinary card in a
/// special place", so the trash's contents *are* `snapshot.trash` and `snapshot.trashedLanes` —
/// already parsed by the loader, already in the column's own order, already newest-first because
/// every arrival stamps `modified` and the container sorts by it descending (re-ruled 2026-07-31).
/// There is nothing to derive, and no second definition to keep in step with the loader's.
///
/// What genuinely remains is what the *commands* need and no view can answer: the two purge
/// confirmations' phrasing — which since lanes rejoined the trash (2026-07-29) has to **count the
/// freight** a trashed lane carries — and the menu validation that stages Delete by place. Both are
/// pure functions of a snapshot and a selection (`TrashModelTests`), so an alert's sentence is
/// testable without an alert on screen. The one number that is not derivable from the snapshot's
/// own shape is the freight itself, and it does not need deriving: the loader counted it at load
/// (`TrashedLane.heldCards`), because the subtree it counts is deliberately not in the snapshot.
public enum TrashModel {

    // MARK: - Counts and phrasing

    /// "41 cards", "1 card" — 06-history-undo.md's **plural folding**.
    public static func phrase(_ count: Int) -> String {
        "\(count) card\(count == 1 ? "" : "s")"
    }

    /// "2 lanes", "1 lane" — the same folding for the container's other kind (03-board-ui.md
    /// § Trash, lanes rejoined 2026-07-29).
    public static func lanePhrase(_ count: Int) -> String {
        "\(count) lane\(count == 1 ? "" : "s")"
    }

    /// **What a purge is about to destroy**, counted honestly: the entries themselves, and the cards
    /// a trashed lane is carrying (03-board-ui.md § Trash: "Confirms name the freight honestly").
    ///
    /// A value rather than three returns because every phrasing below asks the same three questions,
    /// and because "how many cards does this lose" is `cards + freight` in one place rather than at
    /// each call site.
    public struct Freight: Sendable, Equatable {
        /// Trash entries that are cards.
        public let cards: Int
        /// Trash entries that are lanes.
        public let lanes: Int
        /// The cards those lanes are holding — `TrashedLane.heldCards`, summed.
        public let heldCards: Int

        public var isEmpty: Bool { cards == 0 && lanes == 0 }
    }

    /// The freight of a set of resolved trash paths.
    public static func freight(of paths: [ItemPath], in snapshot: BoardModel) -> Freight {
        var cards = 0
        var lanes = 0
        var heldCards = 0
        for path in paths {
            switch path {
            case .trashCard:
                cards += 1
            case .trashLane:
                lanes += 1
                heldCards += snapshot.trashedLanes.first { $0.id == path.id }?.heldCards ?? 0
            case .lane, .card:
                // A board path is not this command's business; the callers resolve in `.trash` and
                // never produce one. Counted as nothing rather than refused, the vanished-target
                // shrug this file gives everywhere.
                continue
            }
        }
        return Freight(cards: cards, lanes: lanes, heldCards: heldCards)
    }

    /// **The aggregate subject both confirmations share** — 03-board-ui.md § Trash's own example
    /// phrasings, with 06-history-undo.md's plural folding: "41 cards", "41 cards and 2 lanes
    /// containing 9 more cards".
    ///
    /// The lane clause says **"more"** only when cards were already counted, because that is the
    /// only reading in which the word means anything; a lane holding nothing contributes no clause
    /// of its own, since "2 lanes containing 0 cards" says less than "2 lanes".
    public static func subject(for freight: Freight) -> String {
        var clauses: [String] = []
        if freight.cards > 0 {
            clauses.append(phrase(freight.cards))
        }
        if freight.lanes > 0 {
            var clause = lanePhrase(freight.lanes)
            if freight.heldCards > 0 {
                let more = freight.cards > 0 ? "more " : ""
                clause += " containing \(freight.heldCards) \(more)card\(freight.heldCards == 1 ? "" : "s")"
            }
            clauses.append(clause)
        }
        return clauses.joined(separator: " and ")
    }

    // MARK: - Confirmations

    /// A purge confirmation's three strings, built once and rendered by the window's alert.
    ///
    /// A value rather than a view so the phrasing rules — plural folding, naming a sole item, and
    /// whether the loss is actually irreversible — are testable without an alert on screen.
    public struct PurgePrompt: Sendable, Equatable {
        public let title: String
        public let message: String
        public let confirmTitle: String
    }

    /// The alert in front of a **permanent** delete — the trash's own ⌫/⌘⌫ (03-board-ui.md §
    /// Trash: "confirms exactly where the loss is real: the alert stands between one keystroke and
    /// unrecoverable deletion").
    ///
    /// A sole entry is **named**; several fold into a count. A sole trashed **lane** names its
    /// freight as well — "Permanently delete lane 'Doing' and its 5 cards" is the design's own
    /// phrasing, and the word *lane* is in it because an opaque row's title alone would not say what
    /// the extra five cards are doing in the sentence.
    ///
    /// **A mixed set is not a gesture this app can produce** — a selection is kind-homogeneous on
    /// both axes (04-interactions.md ▸ The trash) — but the aggregate phrasing covers one anyway
    /// rather than picking a kind to lie about: it is the same sentence Empty Trash builds.
    ///
    /// `nil` when the ids name nothing in the trash, which is also the command's own refusal — so
    /// the prompt and the action can never disagree about whether there is anything to purge.
    public static func purgePrompt(
        for ids: Set<ItemID>,
        snapshot: BoardModel,
        unrecoverable: Bool
    ) -> PurgePrompt? {
        let targets = ItemPath.resolve(ids, in: .trash, snapshot: snapshot)
        guard !targets.isEmpty else { return nil }

        let subject: String
        if targets.count == 1, let only = targets.first {
            let name = "\u{201C}\(displayName(of: only, in: snapshot))\u{201D}"
            if case let .trashLane(id) = only {
                let held = snapshot.trashedLanes.first { $0.id == id }?.heldCards ?? 0
                subject = held > 0 ? "lane \(name) and its \(phrase(held))" : "lane \(name)"
            } else {
                subject = name
            }
        } else {
            subject = self.subject(for: freight(of: targets, in: snapshot))
        }
        return PurgePrompt(
            title: "Permanently delete \(subject)?",
            message: message(unrecoverable: unrecoverable),
            confirmTitle: "Delete"
        )
    }

    /// Empty Trash…'s alert — **always shown** ("Empty Trash… confirms everywhere"), and always
    /// naming the **true count**: every entry in `.trash/`, never the filtered view (03-board-ui.md §
    /// Trash: "search-independent, the confirmation naming the full count").
    ///
    /// Counts rather than names even for a single card, because the command is about the trash
    /// rather than about an item: "Permanently delete 41 cards" and "… 41 cards and 2 lanes
    /// containing 9 more cards" are the design's own example phrasings, and the second is why the
    /// lane freight is counted rather than left implied — a bulk permanent delete must not
    /// understate what it takes.
    public static func emptyTrashPrompt(in snapshot: BoardModel, unrecoverable: Bool) -> PurgePrompt? {
        let freight = Freight(
            cards: snapshot.trash.count,
            lanes: snapshot.trashedLanes.count,
            heldCards: snapshot.trashedLanes.reduce(0) { $0 + $1.heldCards }
        )
        guard !freight.isEmpty else { return nil }
        return PurgePrompt(
            title: "Permanently delete \(subject(for: freight))?",
            message: message(unrecoverable: unrecoverable),
            confirmTitle: "Delete"
        )
    }

    /// The alert's body: whether any of it comes back.
    ///
    /// The tombstone era's second sentence — "Deleting a lane also deletes every card inside it" —
    /// stays gone, and now for a better reason than "no purge reaches a lane": the *title* carries
    /// the freight explicitly ("and its 5 cards"), which is 03's own phrasing and says the same
    /// thing where the user is already reading.
    private static func message(unrecoverable: Bool) -> String {
        // Once slated to answer differently on a git board — there the content stays reachable in
        // history, so the second sentence would have been the honest one — before the excision closed
        // that door (`strategy/01-git-excision.md`, 2026-08-08) and `unrecoverable` is `true`
        // unconditionally (`BoardStore.purgeIsUnrecoverable`).
        unrecoverable
            ? "This can\u{2019}t be undone."
            : "The board\u{2019}s history still has them."
    }

    /// What to call an item in a prompt — its title, or the "Untitled" rendering.
    ///
    /// Total by construction: a path whose item has gone since the prompt was asked for reads
    /// "Untitled" rather than failing, which is the same shrug every other vanished-target rule in
    /// the app gives.
    private static func displayName(of path: ItemPath, in snapshot: BoardModel) -> String {
        switch path {
        case let .lane(id):
            return snapshot.lanes.first { $0.id == id }?.title.value ?? "Untitled"
        case let .card(lane, id):
            return snapshot.lanes.first { $0.id == lane }?
                .cards.first { $0.id == id }?.title.value ?? "Untitled"
        case let .trashCard(id):
            return snapshot.trash.first { $0.id == id }?.title.value ?? "Untitled"
        case let .trashLane(id):
            return snapshot.trashedLanes.first { $0.id == id }?.title.value ?? "Untitled"
        }
    }

    // MARK: - Menu validation

    /// Whether File ▸ Delete has something to act on — **staged by place, but validated once**
    /// (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").
    ///
    /// One predicate for both stagings, because there is only one item now: a board selection moves
    /// into the trash, a trash selection deletes permanently, and the command is enabled whenever
    /// either names something the board still holds. The old mirror-image pair
    /// (which existed to make two ⌘⌫ twins enable exactly one of themselves) retired with Put Back.
    public static func canDelete(selection: ItemReferenceSet, in snapshot: BoardModel) -> Bool {
        !ItemPath.resolve(selection.ids, in: selection.container, snapshot: snapshot).isEmpty
    }
}
