import Foundation

// MARK: - BoardDiff

/// **What changed between two snapshots**, as identities grouped by what happened to them — the
/// summarizer 10-accessibility.md ▸ Live board announcements asks for ("one polite digest per
/// reload debounce — 'Board changed: 2 cards edited, 1 card added'").
///
/// ### Snapshots, not a journal
///
/// The watcher reports *that* the tree changed, never *what* (`WatcherEvent`), and 02-architecture.md
/// is explicit about why that is the right shape: "a value-type snapshot comparison is both cheaper
/// and more trustworthy than trying to reconstruct a tree mutation from a stream of paths that may
/// have been coalesced, dropped, or reordered". So this is a comparison of two `BoardModel` values
/// and nothing else — no FSEvents paths, no receipts, no git. That is also what makes it work
/// regardless of git: app-managed git is gone now (`strategy/01-git-excision.md`, 2026-08-08), and 10
/// once required this in as many words for the no-git case specifically ("on no-git boards the same
/// classifier runs without the committer") — today there is no other case to distinguish it from.
///
/// ### Why it lives in `LiveStore/` and not beside the announcement
///
/// 10 once said announcements "reuse the auto-committer's summarizer" and 06-history-undo.md said the
/// committer synthesized its commit messages from the same comparison — Pro-tier work (pro-m1) that
/// never shipped before app-managed git was excised entirely (`strategy/01-git-excision.md`,
/// 2026-08-08). The summarizer was built here first anyway, in the layer any future consumer can
/// reach: the store layer sits below the history providers, so it stays available to whatever
/// replaces the committer — today the foreign-change journal (`strategy/01-git-excision.md` ▸
/// Successors) — by construction. It imports
/// `Foundation` alone and touches no view, no window, and no `NSAccessibility` — the phrasing is
/// `AccessibilityPhrases`' job, and a future message composer will phrase the very same counts
/// differently without either of them knowing about the other.
///
/// The per-category **identity sets** rather than bare tallies are for that second consumer: a
/// spoken digest only ever needs `count`, but a semantic commit message needs to name the items, and
/// a summarizer that threw the ids away would have to be rewritten rather than extended.
///
/// ### Three counting rules worth stating
///
/// - **Implied events don't steal the subject** (06-history-undo.md's composer discipline, which 10
///   applies to speech in as many words). A lane that vanished takes its cards with it, and a board
///   that says "1 lane deleted, 5 cards deleted" has reported one event twice. So a card is only
///   counted as added or deleted when its lane was *there on both sides*; cards arriving with a new
///   lane and cards leaving with a deleted one are the lane's event, not their own.
/// - **Every changed identity lands in exactly one bucket**, with `moved` outranking `edited`. An
///   agent that re-files a card *and* retitles it made one change to the board, and two fragments
///   counting the same card would read as two cards. Position wins because it is the change the
///   board's shape shows.
/// - **Crossing the trash boundary is a departure or an arrival, never a move** — at **both levels**
///   (lanes rejoined the trash 2026-07-29) — see `between(_:_:includingTrash:)`, whose flag decides
///   whether the trash is walked at all but never what an event that touches it is *called*.
public struct BoardDiff: Sendable, Equatable {

    /// One kind's four buckets. Disjoint by construction — see the type's note on precedence.
    public struct Changes: Sendable, Equatable {

        /// Identities that **arrived on the visible board**: present in the new snapshot and not the
        /// old, minus the ones whose arrival is implied by their lane's — plus, while the trash is
        /// being walked, the cards that crossed *out* of it, which is a restore (the crossing rule,
        /// `between(_:_:includingTrash:)`).
        public var added: Set<ItemID> = []

        /// Identities present on both sides whose *rendered content* differs — title, body, style,
        /// attachments, lane width. Deliberately not `modified`, `modified-by`, or unknown keys: a
        /// digest describes what the board looks like, and a bumped timestamp changes nothing a
        /// user could see. Deliberately not `order` either, which is the `moved` axis.
        public var edited: Set<ItemID> = []

        /// Identities present on both sides that changed position — a different `order`, or (for a
        /// card) a different lane. **Never a trash crossing**, which is a delete or a restore rather
        /// than a re-filing however visible the column happens to be.
        public var moved: Set<ItemID> = []

        /// Identities that **left**: present in the old snapshot and not the new, minus the ones
        /// whose departure is implied by their lane's — plus, while the trash is being walked, the
        /// cards that crossed *into* it. "Deleted" rather than "removed" because that is what it is
        /// from the board's side: deletion is a move into `.trash/` (01-storage-format.md §
        /// Deletion), and a folder moved clean out of the board reads the same way to a user. A
        /// purge and an Empty Trash land here too, once the trash is in the universe at all.
        public var deleted: Set<ItemID> = []

        public init() {}

        public var isEmpty: Bool {
            added.isEmpty && edited.isEmpty && moved.isEmpty && deleted.isEmpty
        }
    }

    public var cards = Changes()
    public var lanes = Changes()

    /// Whether the board side of the snapshot differs **at all** — the backstop for changes no
    /// bucket counts: a renamed board, an edited board description, a bumped stamp.
    ///
    /// **The trash counts here exactly when it is being diffed at all** — `between(_:_:includingTrash:)`'s
    /// flag reaches this too, so a trash card whose stamp an agent bumped trips the backstop while
    /// the column is shown and is silent while it is hidden. Same rule as the buckets, one line
    /// above them.
    public var boardChanged = false

    public init() {}

    /// Nothing to say: no counted change, and no uncounted one either.
    public var isSilent: Bool { !boardChanged && cards.isEmpty && lanes.isEmpty }

    // MARK: - The comparison

    /// The whole summarizer: two snapshots in, one grouped diff out. Pure, total, and free of any
    /// notion of *why* the tree changed — `WatchOrigin` decides whether anyone is told
    /// (`BoardAnnouncer`), never this.
    ///
    /// ### `includingTrash` — the digest's universe, not its vocabulary
    ///
    /// **The digest covers the trash only while the trash lane is shown** (10-accessibility.md ▸
    /// Live board announcements, ruled 2026-07-29): with View ▸ Show Trash on, trash cards are
    /// ordinary elements of the visible board, "so a foreign purge, restore, or Empty Trash joins
    /// the digest like any lane's churn — a user working in the shown trash must hear it emptied
    /// under them; while hidden, trash churn stays silent". Visibility is per-board view state
    /// (`TransientBoardState.isTrashVisible`), which is why it arrives as an argument: this stays a
    /// pure function of two snapshots plus one fact, and the *reading* of that fact belongs to the
    /// reload seam that has a store to ask (`BoardStore.land`).
    ///
    /// The flag widens the **universe** — which cards are walked — and deliberately nothing else.
    /// The digest a shown trash produces is therefore always a superset of the hidden one's, with
    /// every event both can see named identically:
    ///
    /// - A card **entering** the trash is `deleted` and one **leaving** it is `added`, shown or
    ///   hidden. That is the user's own reading of a delete and a restore, and it is not something
    ///   flipping a view toggle should be able to re-word into "1 card moved" (the crossing rule,
    ///   above). Restores therefore already joined the digest before this ruling; the ruling's news
    ///   is the churn that never left the container.
    /// - A **purge** and an **Empty Trash** are `deleted`, an outside writer dropping a folder
    ///   straight into `.trash/` is `added`, and a trash card retitled or reordered in place is
    ///   `edited` or `moved` — all of them silent while the column is hidden, because then the trash
    ///   is not in the universe at all. **Lane rows count in the lane buckets** the same way, so an
    ///   Empty Trash over a trash holding two lanes says "2 lanes deleted" alongside its cards rather
    ///   than understating what went.
    ///
    /// The implied-events rule reaches the crossings too: a lane deleted by moving its cards into
    /// `.trash/` and then removing the folder is still one event, "1 lane deleted", not that plus
    /// its cards.
    public static func between(
        _ old: BoardModel,
        _ new: BoardModel,
        includingTrash: Bool = false
    ) -> BoardDiff {
        var diff = BoardDiff()
        diff.boardChanged = boardSideDiffers(old, new, includingTrash: includingTrash)

        let oldLanes = laneIndex(of: old, includingTrash: includingTrash)
        let newLanes = laneIndex(of: new, includingTrash: includingTrash)

        for id in newLanes.keys where oldLanes[id] == nil {
            diff.lanes.added.insert(id)
        }
        for id in oldLanes.keys where newLanes[id] == nil {
            diff.lanes.deleted.insert(id)
        }
        for (id, newLane) in newLanes {
            guard let oldLane = oldLanes[id] else { continue }
            // **The crossing rule, at the lane level** (lanes rejoined the trash 2026-07-29): a lane
            // that crossed into `.trash/` is a *delete* and one that came back out is a *restore*,
            // which is what the user watched happen — never "1 lane moved". It reads the same shown
            // or hidden, because a lane entering the trash leaves `lanes` either way; what the shown
            // column adds is the churn that never leaves the container — a purged row, an Empty
            // Trash, a foreign lane folder dropped straight into `.trash/`.
            if oldLane.container != newLane.container {
                if newLane.container == .trash {
                    diff.lanes.deleted.insert(id)
                } else {
                    diff.lanes.added.insert(id)
                }
            } else if oldLane.order != newLane.order {
                diff.lanes.moved.insert(id)
            } else if contentDiffers(oldLane, newLane) {
                diff.lanes.edited.insert(id)
            }
        }

        let oldCards = cardIndex(of: old, includingTrash: includingTrash)
        let newCards = cardIndex(of: new, includingTrash: includingTrash)

        for (id, entry) in newCards where oldCards[id] == nil {
            noteArrival(of: id, at: entry.home, in: &diff)
        }
        for (id, entry) in oldCards where newCards[id] == nil {
            noteDeparture(of: id, from: entry.home, in: &diff)
        }
        for (id, newEntry) in newCards {
            guard let oldEntry = oldCards[id] else { continue }
            if oldEntry.home.container != newEntry.home.container {
                // **The crossing rule.** Both sides of the walk hold this card, so the position axis
                // would ordinarily claim it — but a card that crossed into `.trash/` is a *delete*
                // and one that came back out is a *restore*, and those are the words a user would
                // use for what they just watched happen. Routed through the same two notes as an
                // outright arrival and departure so the implied-events rule covers them for free:
                // an agent that empties a lane into the trash and removes it announces the lane.
                if newEntry.home.container == .trash {
                    noteDeparture(of: id, from: oldEntry.home, in: &diff)
                } else {
                    noteArrival(of: id, at: newEntry.home, in: &diff)
                }
            } else if oldEntry.home != newEntry.home || oldEntry.card.order != newEntry.card.order {
                diff.cards.moved.insert(id)
            } else if contentDiffers(oldEntry.card, newEntry.card) {
                diff.cards.edited.insert(id)
            }
        }

        return diff
    }

    /// A card that is on the visible board now and was not before — counted unless **its lane's own
    /// arrival implies it** (a card that came in with a brand-new lane is the lane's event).
    ///
    /// A trash home has no such implication to check: the trash is a standing container, never one
    /// that arrives, so a folder an outside writer dropped into a shown `.trash/` is its own event.
    private static func noteArrival(of id: ItemID, at home: ItemPath, in diff: inout BoardDiff) {
        if case let .card(lane, _) = home, diff.lanes.added.contains(lane) { return }
        diff.cards.added.insert(id)
    }

    /// The mirror, and the rule the vanishing-focus announcement leans on: when a lane goes, the
    /// announcement names the lane and its count, not five cards.
    private static func noteDeparture(of id: ItemID, from home: ItemPath, in diff: inout BoardDiff) {
        if case let .card(lane, _) = home, diff.lanes.deleted.contains(lane) { return }
        diff.cards.deleted.insert(id)
    }

    // MARK: - Indices

    /// A lane the digest can see: on the strip, or — while the trash is being walked — as one of the
    /// column's opaque rows.
    ///
    /// The sum type exists because the two are genuinely different values (`TrashedLane` is not a
    /// `Lane`, and deliberately so), while the three questions the comparison asks of a lane —
    /// which container, which rank, does its rendered content differ — have an answer for both.
    private enum LaneEntry {
        case live(Lane)
        case trashed(TrashedLane)

        var container: ItemContainer {
            switch self {
            case .live: .board
            case .trashed: .trash
            }
        }

        var order: Double {
            switch self {
            case let .live(lane): lane.order
            case let .trashed(lane): lane.order
            }
        }
    }

    /// Every lane the digest can see, with where it sits — `cardIndex`'s twin, and with the same
    /// flag: with `includingTrash` off the column's rows are simply not walked, which is what makes
    /// a purge in a hidden trash silent rather than something filtered back out downstream.
    private static func laneIndex(of snapshot: BoardModel, includingTrash: Bool) -> [ItemID: LaneEntry] {
        var index: [ItemID: LaneEntry] = [:]
        for lane in snapshot.lanes {
            index[lane.id] = .live(lane)
        }
        guard includingTrash else { return index }
        for lane in snapshot.trashedLanes {
            index[lane.id] = .trashed(lane)
        }
        return index
    }

    /// Every card the digest can see, with **where it sits** — the lane it is in, or the trash.
    ///
    /// `ItemPath` rather than a bare lane id because the home is now two-valued and the comparison
    /// asks three questions of it: which container (the crossing rule), which lane (the move axis),
    /// and whether that lane is itself new or gone (the implied-events rule). The type that already
    /// spells "a card in a lane, or a card in the trash" answers all three, and a hand-rolled
    /// optional-lane pair could spell a fourth thing that does not exist.
    ///
    /// With `includingTrash` off the trash is simply not walked, which is what makes trash churn
    /// silent rather than something filtered back out downstream.
    private static func cardIndex(
        of snapshot: BoardModel,
        includingTrash: Bool
    ) -> [ItemID: (home: ItemPath, card: Card)] {
        var index: [ItemID: (home: ItemPath, card: Card)] = [:]
        for lane in snapshot.lanes {
            for card in lane.cards {
                index[card.id] = (.card(lane: lane.id, id: card.id), card)
            }
        }
        guard includingTrash else { return index }
        for card in snapshot.trash {
            index[card.id] = (.trashCard(card.id), card)
        }
        return index
    }

    // MARK: - Content

    /// A card's rendered content, field by field rather than by whole-value equality.
    ///
    /// Spelled out because whole-value equality would fold in `order` (the `moved` axis) and the
    /// `modified`/`modified-by` stamps, so every reorder would also read as an edit and every
    /// touch-only rewrite would announce a change nobody could see. A rendered field added to `Card`
    /// belongs on this list — the cost of the explicitness, recorded so it is not rediscovered.
    private static func contentDiffers(_ old: Card, _ new: Card) -> Bool {
        old.schema != new.schema
            || old.title != new.title
            || old.body != new.body
            || old.background != new.background
            || old.icon != new.icon
            || old.iconColor != new.iconColor
            || old.attachments != new.attachments
    }

    /// Two lane entries' rendered content.
    ///
    /// **A trashed row's rendered content is its title and nothing else** — the row draws a title and
    /// a held-card count and takes no styling accents (03-board-ui.md § Trash), so a `background` an
    /// agent wrote onto a folder sitting in the trash changes nothing anyone can see and must not
    /// announce. The count would be visible, but it is a fact about the subtree the loader counts and
    /// not a field on the row; a card added inside a trashed lane is churn in a container the design
    /// calls opaque, and the digest is deliberately as opaque about it.
    ///
    /// A crossing never reaches here — it is decided one level up — so the mixed pair is unreachable
    /// and answers "nothing differs" rather than inventing a comparison between two kinds.
    private static func contentDiffers(_ old: LaneEntry, _ new: LaneEntry) -> Bool {
        switch (old, new) {
        case let (.live(old), .live(new)):
            contentDiffers(old, new)
        case let (.trashed(old), .trashed(new)):
            old.schema != new.schema || old.title != new.title
        case (.live, .trashed), (.trashed, .live):
            false
        }
    }

    /// A lane's rendered content — the card's list, minus attachments (a lane has none) plus
    /// `width`, which is a lane's own visible property (03-board-ui.md § Lane). A lane's *cards*
    /// are diffed as cards, never folded in here.
    private static func contentDiffers(_ old: Lane, _ new: Lane) -> Bool {
        old.schema != new.schema
            || old.title != new.title
            || old.body != new.body
            || old.background != new.background
            || old.icon != new.icon
            || old.iconColor != new.iconColor
            || old.width != new.width
    }

    /// Whether anything the digest can see differs — the whole value while the trash is being
    /// walked, everything but the trash while it is not.
    ///
    /// `trash` and `trashedLanes` are the two `var`s on `BoardModel`, which is what makes "everything
    /// except the trash" expressible as value equality rather than as a second field-by-field list
    /// that would go stale the moment the model grows a field. Both are cleared, for one reason: a
    /// trashed lane is trash, so churn among the rows must stay invisible while the column is hidden
    /// — a lane *entering* the trash is still seen, because it left `lanes`, which is the board side
    /// and is exactly the "1 lane deleted" event the user watched happen.
    private static func boardSideDiffers(
        _ old: BoardModel,
        _ new: BoardModel,
        includingTrash: Bool
    ) -> Bool {
        guard !includingTrash else { return old != new }
        var oldSansTrash = old
        var newSansTrash = new
        oldSansTrash.trash = []
        newSansTrash.trash = []
        oldSansTrash.trashedLanes = []
        newSansTrash.trashedLanes = []
        return oldSansTrash != newSansTrash
    }
}
