import Foundation

// MARK: - BoardAnnouncer

/// **What a landing reload says out loud, and where focus goes when it lands on a hole** —
/// 10-accessibility.md ▸ Live board announcements, as pure functions of the two snapshots, the
/// reload's origin, and the standing banner conditions.
///
/// ### Why the whole decision is a value function
///
/// The rules 10 states are all *decisions*, not effects: which origins speak ("foreign changes
/// announce, app-mediated echoes never do"), what one reload is allowed to say ("one polite digest
/// per reload debounce … never per-file chatter"), which sentence wins when two apply, and where a
/// vanished focus recovers to ("walks up then sideways"). Every one of them is checkable without a
/// window, a screen reader, or a running app — so every one of them lives here, and the store keeps
/// only the two thin acts a pure function cannot perform: posting the notification and moving the
/// selection. `Motion` is the same shape for the same reason, on the same seam.
///
/// ### The ladder, and why speech is rationed
///
/// "Exactly one announcement per reload" is the whole point of the debounce — a board being
/// rewritten by an agent must not turn VoiceOver into a ticker — so `speech(for:)` returns **one
/// optional sentence**, chosen by precedence rather than concatenated:
///
/// 1. **A standing condition that just appeared** — the read-only lock or reload breakage. 10 makes
///    the live-reload-resilience banner an announced element in its own right, and a board that has
///    just stopped being writable outranks any description of what changed in it.
/// 2. **A bracketed operation's completion phrase** — "announce once, at completion, never their
///    internal churn". A bracket's reload is the operation's result; the churn inside it is not the
///    user's business and its digest would describe a tree they never saw.
/// 3. **A standing condition that just cleared** — the other half of "announced when it appears and
///    when it clears". Below completion because a completion phrase already implies the board is
///    reading and writing again.
/// 4. **A vanished focus** — the specific sentence, which beats the generic one (below).
/// 5. **The board digest** — the ordinary foreign-change case.
///
/// ### Provenance is per file, and it arrives already decided
///
/// **The announcer consumes the ledger's per-file facts on every reload origin, reconciling
/// included** (10-accessibility.md, ruled 2026-07-29). So there is no origin on `ReloadFacts` and no
/// origin test in the ladder: what buys an app-mediated echo its silence is that its files carry
/// receipts the disk still matches (`EchoLedger`), and the reload seam hands this type a diff
/// already narrowed to the changes nobody vouched for. A reconciling sweep over a blind window —
/// wake, activation, a missed-events flag — reveals files with no receipts at all, so they classify
/// foreign and the sweep speaks: "the app never vouches for changes it didn't witness", applied to
/// speech. Rungs 4 and 5 are therefore *foreign-only by construction* rather than by a gate.
///
/// Rungs 1 through 3 never had one and still do not: **banner transitions are origin-independent**
/// (10, confirmed 2026-07-29) — a lock raised by the app's own bracketed operation is exactly the
/// case 10 names ("including the read-only lock after a failed bracketed reload"), and silence there
/// would be the app hiding its own failure. Rung 2 is the one exemption from the ledger in either
/// direction: a bracket announces its result and never its churn, so 02-architecture.md keeps
/// bracketed operations out of the ledger entirely.
public enum BoardAnnouncer {

    // MARK: - A vanishing focus

    /// What disappeared under the focus, as the announcement's *subject*.
    ///
    /// Two cases and not three, because 10 settles the composition: when the lane itself vanished
    /// the announcement names the **lane**, not the card — "the implied-events-don't-steal-the-
    /// subject discipline of 06-history-undo.md's composer, applied to speech". So a focused card
    /// whose lane was deleted produces `.lane`, and `.card` is reserved for a card that vanished out
    /// of a lane that is still there.
    public enum VanishedFocus: Sendable, Equatable {
        case card(title: String?)
        case lane(title: String?, cards: Int)
    }

    /// Where focus lands after the item under it stopped existing.
    ///
    /// **Never the trash.** 10 is explicit — "never into the trash, which stays hidden — no layout
    /// side effects from a foreign edit" — so the two cases are the two board-side destinations and
    /// there is no third to spell.
    public enum FocusRecovery: Sendable, Equatable {
        /// Select this lane: the focused card's own lane when it survived, otherwise the lane now
        /// occupying the vanished lane's position.
        case lane(ItemID)
        /// Select nothing — the board container itself, "only when no lanes remain".
        case boardContainer
    }

    /// A reload's effect on focus: what to say about it, and where to put it.
    ///
    /// **Naming and recovery are independent axes** (10-accessibility.md, ruled 2026-07-29). A
    /// recovery never arrives without a sentence — describing a move without saying what caused it
    /// would leave the user somewhere new for no stated reason — but a sentence *can* arrive
    /// without a move, and that is exactly the surviving-co-selection case: the thing under the
    /// cursor was deleted, which is what the rule exists to say, while the survivors veto the move
    /// because a reload never edits a selection the user still partly holds. So the pairing is
    /// "recovery implies vanished", not "both or neither".
    public struct FocusOutcome: Sendable, Equatable {
        public var vanished: VanishedFocus?
        public var recovery: FocusRecovery?

        public init(vanished: VanishedFocus? = nil, recovery: FocusRecovery? = nil) {
            self.vanished = vanished
            self.recovery = recovery
        }

        /// Nothing happened to focus — the overwhelmingly common outcome, and the one every guard
        /// below falls out to.
        public static let survived = FocusOutcome()
    }

    /// Whether this reload pulled the ground out from under the focused item, and where focus goes
    /// if it did.
    ///
    /// `focused` is the *cursor*, not the set: `TransientBoardState.selectionHead` when it is still
    /// in the selection, else a sole selected item (`BoardStore.focusedItem`). 10 speaks of "the
    /// selected or VO-focused card" in the singular, and naming the cursor's card out of five is
    /// exactly the sentence the rule wants — naming an *arbitrary* one of the five, which is what a
    /// selection with no cursor could offer, is the worse answer the digest already beats.
    ///
    /// ### Two guards and a veto, each of them a rule
    ///
    /// - **The board container only.** A trash selection is not on the board, 10 keeps focus out of
    ///   the trash on principle, and there is no lane to recover to from in there.
    /// - **The focus must actually be gone.** A reload that reordered the board around a surviving
    ///   card has nothing to announce and nothing to recover.
    /// - **Survivors veto the recovery — and only the recovery** (ruled 2026-07-29). If any other
    ///   selected item is still there, the selection already sits somewhere the user chose; moving
    ///   it to a lane would be the reload editing a live selection, and 02-architecture.md's
    ///   re-resolution rule (the head re-anchors within the surviving selection, no substitute is
    ///   invented) stands untouched for that case. The *sentence* is not the move's passenger,
    ///   though: "the thing under the cursor was deleted, and that is what the rule exists to say",
    ///   so the head is named whether or not anything survived it. Vanished non-head members stay
    ///   unnamed and fall to the digest's counts, which is what makes this the head's rule rather
    ///   than the set's.
    ///
    /// The veto is why this can be layered on `ItemReferenceSet.resolved(against:)` rather than
    /// replacing it: the set rule still runs first and still invents nothing; this decides what to
    /// say about the hole it leaves and, when the hole is the whole selection, what to do about it.
    public static func focusOutcome(
        old: BoardModel,
        new: BoardModel,
        selection: ItemReferenceSet,
        focused: ItemID?
    ) -> FocusOutcome {
        guard selection.container == .board,
              let focused,
              selection.ids.contains(focused)
        else { return .survived }

        let universe = ItemContainer.board.ids(in: new)
        guard !universe.contains(focused) else { return .survived }

        // Computed once and applied to every branch below: the three destinations differ, the veto
        // over all of them does not.
        let survivors = !selection.ids.isDisjoint(with: universe)
        func recovery(_ destination: @autoclosure () -> FocusRecovery) -> FocusRecovery? {
            survivors ? nil : destination()
        }

        // A focused *lane* that vanished is already the lane case — no card to be displaced by.
        if let lane = old.lanes.first(where: { $0.id == focused }) {
            return FocusOutcome(
                vanished: .lane(title: lane.title.value, cards: lane.cards.count),
                recovery: recovery(successorLane(of: lane.id, old: old, new: new))
            )
        }

        guard let home = old.lanes.first(where: { lane in lane.cards.contains { $0.id == focused } }),
              let card = home.cards.first(where: { $0.id == focused })
        else {
            // The focus was not on the board's old side either — a stale reference no reload can
            // describe. Silence and no movement is the honest answer.
            return .survived
        }

        if new.lanes.contains(where: { $0.id == home.id }) {
            return FocusOutcome(
                vanished: .card(title: card.title.value),
                recovery: recovery(.lane(home.id))
            )
        }
        return FocusOutcome(
            vanished: .lane(title: home.title.value, cards: home.cards.count),
            recovery: recovery(successorLane(of: home.id, old: old, new: new))
        )
    }

    /// **Walk up, then sideways** (10, settled): the lane now occupying the vanished lane's
    /// position — the next lane by `order`, else the previous one — and the board container only
    /// when no lanes remain.
    ///
    /// This is 04-interactions.md's ⌫-successor pattern applied to external change, and it is
    /// computed against the **old** lane list because that is the only place the vanished lane still
    /// has a position. `BoardModel.lanes` is already in display order (`Ranks.sortedForDisplay`), so
    /// "next by `order`" is the next surviving element and needs no sort of its own.
    ///
    /// The final fallback is `new.lanes.first` rather than the board container: 10 reserves the
    /// container for "when no lanes remain", so a reload that replaced every lane with different
    /// ones — a checkout, a template applied by an agent — lands on the leftmost of what is actually
    /// there rather than nowhere.
    static func successorLane(of vanished: ItemID, old: BoardModel, new: BoardModel) -> FocusRecovery {
        let surviving = Set(new.lanes.map(\.id))
        guard let position = old.lanes.firstIndex(where: { $0.id == vanished }) else {
            return new.lanes.first.map { .lane($0.id) } ?? .boardContainer
        }
        for lane in old.lanes[old.lanes.index(after: position)...] where surviving.contains(lane.id) {
            return .lane(lane.id)
        }
        for lane in old.lanes[..<position].reversed() where surviving.contains(lane.id) {
            return .lane(lane.id)
        }
        return new.lanes.first.map { .lane($0.id) } ?? .boardContainer
    }

    // MARK: - The reload's one sentence

    /// Everything one landed reload knows that could bear on what it says — gathered into a value so
    /// the decision below reads as a ladder rather than as a nine-argument call, and so a test can
    /// state one fact and default the rest.
    public struct ReloadFacts: Sendable, Equatable {

        /// Whether this is the reload that closes a bracketed wholesale operation.
        public var endsBracketedOperation = false

        /// What that operation wants said when it lands — "Pulled 3 commits", "Switched to branch
        /// 'redesign'", both git's. `nil` for a bracket whose completion is not worth speech, which
        /// is **every bracket today**: nothing in the app passes a phrase, and the parameter is the
        /// seam a future wholesale operation fills (see `BoardStore.performWholesale(announcing:_:)`).
        public var completion: String?

        /// The snapshot comparison **already narrowed to the foreign-classified changes**
        /// (`EchoLedger.verdicts(from:to:diff:includingTrash:)`), empty by default so a test about
        /// the ladder need not build one. An app-mediated echo reaches here as an empty diff, which
        /// is why the digest rung needs no gate of its own.
        public var diff = BoardDiff()

        /// What went out from under the cursor, if anything (`focusOutcome(old:new:selection:focused:)`).
        public var vanishedFocus: VanishedFocus?

        /// The read-only lock before and after this reload landed. Compared rather than merely
        /// presence-checked so a lock whose *cause* changed re-announces: "this board's folder is
        /// gone" replacing "this board's location can't be written to" is news.
        public var lockBefore: ReadOnlyLockReason?
        public var lockAfter: ReadOnlyLockReason?

        /// The reload-breakage condition before and after, same rule: a different file failing to
        /// load is a different sentence and is worth saying. **The whole aggregate**, not just its
        /// first defect — a second broken lane appearing under an already-broken one changes the
        /// sentence the strip is showing ("and 2 more"), so it is news by the same test.
        public var breakageBefore: BoardLoadFailure?
        public var breakageAfter: BoardLoadFailure?

        public init() {}
    }

    /// The ladder — see the type's doc comment for the five rungs and why they are in that order.
    /// `nil` is the answer for the overwhelming majority of reloads, which is the design's intent:
    /// the board is quiet unless something happened that a user who cannot see it needs told.
    public static func speech(for facts: ReloadFacts) -> String? {
        if let raised = raisedCondition(facts) { return raised }
        if facts.endsBracketedOperation { return facts.completion }
        if let cleared = clearedCondition(facts) { return cleared }

        // **App-mediated echoes never do**, and there is deliberately no test for that here. The
        // user's own action already had its feedback — the gesture, the animation, the menu it came
        // from — and narrating it back is the app talking over the user; what enforces it is the
        // ledger upstream, which hands this an empty diff and no vanished focus when every file the
        // reload observed carries a receipt disk still matches. A reconciling sweep that reveals
        // nothing arrives the same way and is silent for the same reason — not because of its label.
        if let vanished = facts.vanishedFocus {
            return AccessibilityPhrases.vanishedFocus(vanished)
        }
        return AccessibilityPhrases.boardChanged(facts.diff)
    }

    // MARK: - A card window's thread

    /// **What a card window's landed thread re-read says out loud** — the ladder's sibling for the one
    /// kind of change the board's snapshot cannot see (10-accessibility.md ▸ Comments;
    /// 01-storage-format.md ▸ Enhanced schema's path shape).
    ///
    /// ### Why it is beside the ladder rather than a sixth rung on it
    ///
    /// `speech(for:)` answers "what does *this board reload* say", and its rationing — one sentence per
    /// reload debounce — is about the board window's own churn. A thread change is not in that reload's
    /// picture at all: comments are outside the snapshot, so the facts arrive from the card window's
    /// own re-read, on a different window, about a different element. Folding it into the ladder would
    /// mean either the board reload waiting on a window-scoped disk read (which 01 rules out) or the
    /// ladder silently dropping a comment sentence whenever a banner also moved.
    ///
    /// What it *does* share is everything that matters: the same doctrine (foreign only — the narrowing
    /// is the caller's, through `CommentThreadChanges.excluding(_:)`), the same rationing (one optional
    /// sentence, chosen by precedence), the same vocabulary (`AccessibilityPhrases`), and the same
    /// posting seam (`AccessibilityAnnouncer.post`, medium priority, attributed to the key window —
    /// which for a thread change is the card window the user is looking at).
    ///
    /// `nil` — silence — for a reload that changed nothing in the thread, which is the overwhelming
    /// majority of them, and for one whose every change the app itself wrote.
    public static func commentSpeech(for changes: CommentThreadChanges, onCard title: String?) -> String? {
        guard !changes.isEmpty else { return nil }
        return AccessibilityPhrases.commentsChanged(changes, onCard: title)
    }

    /// A standing condition that was not there before and is now — announced with the banner row's
    /// own label, so the sentence a VoiceOver user hears is the sentence the strip is showing.
    private static func raisedCondition(_ facts: ReloadFacts) -> String? {
        if let lock = facts.lockAfter, lock != facts.lockBefore {
            return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: lock))
        }
        if let breakage = facts.breakageAfter, breakage != facts.breakageBefore {
            return AccessibilityPhrases.bannerLabel(tone: .error, headline: BannerCenter.headline(for: breakage))
        }
        return nil
    }

    /// A standing condition that has healed. The lock leads when both clear at once: it is the one
    /// that was refusing writes, and "you can edit again" subsumes "it is loading again".
    private static func clearedCondition(_ facts: ReloadFacts) -> String? {
        if facts.lockBefore != nil, facts.lockAfter == nil {
            return AccessibilityPhrases.readOnlyLockCleared
        }
        if facts.breakageBefore != nil, facts.breakageAfter == nil {
            return AccessibilityPhrases.reloadBreakageCleared
        }
        return nil
    }
}
