import CryptoKit
import Foundation
import Synchronization

// MARK: - Harvested receipts

/// **One EchoLedger receipt, copied out for a provenance consumer** (02-architecture.md ▸
/// Components ▸ EchoLedger; 06-history-undo.md ▸ Interaction with external writers).
///
/// Relocated 2026-08-08 from `CommitAttribution.swift` with the git excision — the harvest surface
/// outlives its git consumer; it is foundation for the deferred foreign-change journal
/// (`strategy/01-git-excision.md`).
///
/// ### Why a copy and not a read
///
/// The ledger's receipts are **consumed** by the landing reload that classifies them — "one write,
/// one echo", which is what buys the announcer its silence. A consumer asking its question a
/// debounce later finds every receipt for the user's own card edit already gone; reading the live
/// ledger at that point would misattribute the user's own work to a foreign writer, which is the
/// one misattribution this whole mechanism exists to prevent.
///
/// So a consumer harvests at the **close of each write bracket** — the moment a receipt describes
/// a completed write and nothing has had a chance to consume it — and keeps its own copy for the
/// life of the debounce window. Supersession still works: a later bracket's harvest overwrites the
/// same key with the newer hash, exactly as the ledger's own `recordWrite` does.
///
/// The satisfaction check stays the ledger's rule, re-applied against disk at attribution time, so
/// the two races 02 settles land the same way here: byte-identical foreign bytes over a fresh app
/// write classify app-mediated, and a foreign edit that misses the hash classifies foreign.
public struct HarvestedReceipt: Sendable, Equatable {

    public let receipt: EchoLedger.Receipt

    /// **Whether the write that dropped it was a heal** — the flag 06-history-undo.md (ruled
    /// 2026-07-29) keys the third commit class on: "a debounce window holding a scheduled heal's
    /// changes alongside anyone else's splits the heal's paths into their own commit".
    public let isHeal: Bool

    public init(receipt: EchoLedger.Receipt, isHeal: Bool) {
        self.receipt = receipt
        self.isHeal = isHeal
    }
}

// MARK: - EchoLedger

/// **What the app wrote, so a landing reload can tell its own echo from someone else's edit** —
/// 02-architecture.md ▸ Components ▸ EchoLedger, built in the store layer ahead of any consumer
/// that would record what it says (ruled 2026-07-29).
///
/// ### The one thing it is not
///
/// It never feeds the render path. 02's layering rule is absolute — "the snapshot is only ever
/// built from disk, never from memory of what the app meant to write" — and provenance is the
/// separate, *downstream* concern this type is: the loader has already produced the snapshot before
/// a single receipt is consulted, and consulting them cannot change it. `EchoLedgerTests` pins that
/// with a poisoned ledger and an unchanged snapshot, because it is the invariant the whole one-way
/// flow rests on.
///
/// ### Receipts
///
/// "Every BoardWriter operation drops a receipt of its expected on-disk outcome before returning:
/// path → content hash for writes …, an absence marker for deletes, an old→new pair for folder
/// moves; a newer app write to the same path supersedes the receipt." The drops live inside
/// `BoardWriter`, at the four primitives that actually touch disk (`atomicReplace`, the folder move,
/// the removal, the attachment copy), so no *call site* does bookkeeping and a new Writer operation
/// built from those primitives is covered the day it is written. `BoardStore.performWrite` is what
/// makes them reachable: it binds this store's ledger to `EchoLedger.current` for the bracket's
/// duration, so a Writer static — stateless, `Sendable`, owned by nobody — can drop a receipt into
/// the ledger of the board it is writing without being handed one.
///
/// **Bracketed operations are deliberately outside that seam.** `performWholesale` does not bind a
/// ledger: 02 says bracketed operations "don't consult it (they commit themselves and announce once
/// at completion)", so receipts from inside a bracket would be litter nothing would ever consume.
///
/// ### Classification
///
/// "Classification runs per observed changed file in a debounce window: current on-disk content
/// matches the receipt → **app-mediated**, receipt consumed; no receipt, or mismatch → **foreign**."
/// `verdicts(from:to:diff:includingTrash:)` is that rule applied to the unit a reload can actually
/// observe — see its own note on why *files* are reached through *items*.
///
/// **Final content decides the races**, which is 02's settled answer to both of them: an agent
/// writing byte-identical bytes over a fresh app write matches the hash and classifies app-mediated
/// (with identical bytes the misattribution is unobservable in the tree, accepted), and a foreign
/// edit landing on an app-written path inside the same window misses the hash and classifies foreign
/// (last writer wins the file).
///
/// ### In-memory, per-store, dies with the session
///
/// A `let` on `BoardStore`, with no persistence and no recovery: "losing it costs attribution and
/// nothing else", and everything a lost ledger cannot vouch for classifies foreign — *the app never
/// vouches for changes it didn't witness*. That is also why an unsatisfied receipt is **not**
/// consumed: keeping it makes the next observation of that item foreign too, which is the
/// conservative direction, and a receipt that is later satisfied again is exactly the
/// byte-identical race the design already accepts.
public final class EchoLedger: Sendable {

    // MARK: - Vocabulary

    /// What one observed change turned out to be.
    public enum Provenance: Sendable, Equatable {
        /// The app wrote this, and disk still agrees with what it wrote.
        case appMediated
        /// Nobody vouched for it — no receipt, or a receipt disk no longer matches.
        case foreign
    }

    /// One completed write's expected on-disk outcome, keyed by the path it describes.
    ///
    /// Three cases and not four, because a folder move is **one** fact filed under two keys: the
    /// pair is stored at both endpoints so whichever end a reload observes finds it, and consuming
    /// either end retires both. Splitting it into a departure and an arrival would let half of a
    /// move be consumed and the other half linger forever.
    public enum Receipt: Sendable, Equatable {
        /// A file write: these are the bytes that landed.
        case content(hash: String)
        /// A delete: nothing should be here.
        case absence
        /// A folder move: what used to be at `from` is at `to`.
        case move(from: String, to: String)
    }

    /// What the landing reload saw at a path.
    ///
    /// `.present` is the honest middle case rather than a missing one: the snapshot **names** a
    /// card's attachments but never reads their bytes, so an attachment receipt can only ever be
    /// checked for arrival — and checking it that way costs no I/O on the reload seam, which is the
    /// whole reason classification is a function of two snapshots and nothing else.
    public enum Observation: Sendable, Equatable {
        case content(hash: String)
        case present
        case absent
    }

    /// The ledger the Writer drops receipts into for the duration of one `performWrite` bracket.
    ///
    /// A task-local rather than a parameter on forty Writer signatures, and rather than a property
    /// on `BoardWriter` (which is a stateless `enum` of statics and must stay one — 02 ▸ Components:
    /// "No hidden state"). The binding is dynamically scoped to exactly the bracket, which is
    /// exactly the lifetime "a receipt describes a *completed* write, and the ledger lives beside
    /// the Writer, not in it" asks for. `nil` — a Writer call outside any bracket, a test, a write
    /// into some *other* board's tree (Duplicate, template instantiation) — records nothing, which
    /// is what those paths want: there is no session whose echoes they are.
    @TaskLocal public static var current: EchoLedger?

    /// One receipt plus its attributes. Split from `Receipt` so the public vocabulary stays the
    /// three outcomes it always was and an attribute can be added without every reader learning a
    /// new shape.
    private struct Entry: Sendable, Equatable {
        var receipt: Receipt
        /// **Whether the write that dropped this receipt was a heal** (06-history-undo.md ▸ Commit
        /// messages, ruled 2026-07-29: "the Writer's heal operations drop heal-marked receipts in
        /// the EchoLedger — attribution machinery like the author split, never message tagging").
        ///
        /// **Inert beyond the ledger itself.** Nothing here reads it and nothing renders it; it is
        /// the flag whatever records history next — the foreign-change journal
        /// (`strategy/01-git-excision.md` ▸ Successors) is the candidate — would read to split a
        /// heal's paths out from a user's own, and it is stored rather than derived because by the
        /// time anything asks, the only thing that still knows a path was healed is the receipt.
        var isHeal: Bool = false
    }

    private let receipts = Mutex<[String: Entry]>([:])

    public init() {}

    // MARK: - Hashing

    /// SHA-256, hex. The hash's only job is equality, and a cryptographic digest is the one kind
    /// that can be compared without a second thought about adversarial or accidental collisions in
    /// a file the user's agent is also writing.
    public static func hash(of data: Data) -> String {
        SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
    }

    /// The text form, which is the one every `index.md` write uses: `BoardWriter.atomicReplace`
    /// emits `Data(text.utf8)`, so hashing the same text hashes the same bytes.
    public static func hash(of text: String) -> String {
        hash(of: Data(text.utf8))
    }

    /// A path in the one spelling the ledger keys on. `standardizedFileURL` and not
    /// `resolvingSymlinksInPath`: the latter touches the filesystem, and both the Writer's URLs and
    /// the loader's derive from the same `BoardStore.rootURL`, so there is nothing to resolve.
    static func key(_ url: URL) -> String {
        url.standardizedFileURL.path
    }

    // MARK: - Recording

    /// A file write — the receipt for the bytes that just landed.
    public func recordWrite(at url: URL, text: String) {
        recordWrite(atPath: Self.key(url), hash: Self.hash(of: text))
    }

    /// A file write whose bytes are already in hand.
    public func recordWrite(at url: URL, data: Data) {
        recordWrite(atPath: Self.key(url), hash: Self.hash(of: data))
    }

    /// The path-and-hash form — **supersession** lives here, as a plain overwrite: "a newer app
    /// write to the same path supersedes the receipt", because only the final content decides.
    public func recordWrite(atPath path: String, hash: String) {
        // The heal mark is **not** carried over: supersession replaces the whole receipt, and a
        // later ordinary write to the same path is exactly the case where the path stops being the
        // heal's alone. `markHeal(at:)` is called after the write it describes, never before.
        receipts.withLock { $0[path] = Entry(receipt: .content(hash: hash)) }
    }

    /// An attachment import — "attachment imports hash during the copy (the bytes stream through
    /// the app anyway)".
    ///
    /// **A judgment call, recorded**: they do not, quite. `BoardWriter.importAttachments` hands the
    /// copy to `FileManager.copyItem`, which never brings the bytes into this process, so the hash
    /// is taken from the landed file the instant the copy returns — memory-mapped, so a large
    /// attachment costs a digest pass rather than a resident copy. A file that cannot be read back
    /// records **nothing**: a receipt whose hash is a guess is worse than no receipt, and no receipt
    /// means the import classifies foreign, which is the safe direction.
    public func recordImport(at url: URL) {
        guard let data = try? Data(contentsOf: url, options: [.mappedIfSafe]) else { return }
        recordWrite(at: url, data: data)
    }

    /// A delete — the absence marker.
    ///
    /// Everything the ledger held *below* the path goes with it: a folder that is gone cannot have
    /// an `index.md` whose hash still means anything, and leaving those receipts behind would make
    /// the next thing to appear at that path inherit them.
    public func recordDeletion(at url: URL) {
        recordDeletion(atPath: Self.key(url))
    }

    public func recordDeletion(atPath path: String) {
        receipts.withLock { store in
            Self.forget(&store, under: path)
            store[path] = Entry(receipt: .absence)
        }
    }

    /// A folder move — the old→new pair, filed at both ends (see `Receipt.move`).
    ///
    /// Receipts *below* the old path are rebased onto the new one rather than dropped: the bytes did
    /// not change, only where they are, and an `index.md` receipt from earlier in the same bracket
    /// is still the truth about the file that just travelled.
    public func recordMove(from source: URL, to destination: URL) {
        recordMove(fromPath: Self.key(source), toPath: Self.key(destination))
    }

    public func recordMove(fromPath source: String, toPath destination: String) {
        receipts.withLock { store in
            for path in Array(store.keys) where path.hasPrefix(source + "/") {
                store[destination + path.dropFirst(source.count)] = store.removeValue(forKey: path)
            }
            let pair = Entry(receipt: .move(from: source, to: destination))
            store[source] = pair
            store[destination] = pair
        }
    }

    /// **Marks the receipt at `path` as a heal** — the Writer's heal operations call this on the
    /// paths they touched, after the bytes land (06-history-undo.md ▸ Commit messages, ruled
    /// 2026-07-29).
    ///
    /// A no-op where there is no receipt (a Writer call outside any bracket, a test): a mark with no
    /// receipt to attach to would describe nothing. It never *creates* a receipt for the same reason
    /// — the receipt is the record of a completed write, and this only ever adds an attribute to
    /// one that already exists.
    ///
    /// Both ends of a move pair are one fact filed under two keys, so marking either marks both.
    public func markHeal(at url: URL) {
        markHeal(atPath: Self.key(url))
    }

    public func markHeal(atPath path: String) {
        receipts.withLock { store in
            guard var entry = store[path] else { return }
            entry.isHeal = true
            store[path] = entry
            if case let .move(from, to) = entry.receipt {
                let other = path == from ? to : from
                if var twin = store[other] {
                    twin.isHeal = true
                    store[other] = twin
                }
            }
        }
    }

    /// **Marks everything this ledger holds as a heal** — the whole-ledger form of `markHeal(at:)`,
    /// for a ledger whose *every* receipt is a heal by construction.
    ///
    /// Its one caller is the decision surface's repair bracket (01-storage-format.md § Malformed
    /// input: "On Pro boards the repairs drop heal-marked receipts and commit separately as one
    /// repair commit"). Repairs run **before** the board has a store — there is no `BoardStore` yet,
    /// so no `performWrite` to bind — so the repair binds a ledger of its own for the duration of the
    /// bracket. Everything that lands in it is a repair, which is exactly the condition that makes a
    /// blanket mark honest here and would make it a lie on a session ledger.
    ///
    /// Path-by-path marking would need the repair runner to enumerate the files each `BoardWriter`
    /// call happened to touch — `createBoard` writes `index.md` *and* seeds `.gitignore` — which is
    /// bookkeeping the Writer exists to keep call sites out of.
    public func markAllAsHeal() {
        receipts.withLock { store in
            for path in store.keys {
                store[path]?.isHeal = true
            }
        }
    }

    /// **Takes over another ledger's receipts, attributes and all** — the repair bracket's ledger
    /// handed to the board's own once the board finally has one.
    ///
    /// The decision surface repairs a board that has no store, then re-walks it; the walk succeeds,
    /// the store is built, and only *then* does a session ledger exist. Without this the repair's
    /// receipts would die with the temporary ledger and Pro's committer would author the app's own
    /// repair as `Lanework External` — the one misattribution the whole mechanism exists to prevent.
    ///
    /// **Safe because a receipt vouches against disk, not against a clock** (`Receipt.isSatisfied`):
    /// the repaired files are on disk exactly as the repair left them, and the re-walk that just
    /// succeeded read those very bytes. An adopted receipt is therefore satisfiable the moment it
    /// arrives, which is the same standing a receipt dropped inside a write bracket has.
    ///
    /// Plain overwrite, the supersession rule: the adopting ledger is brand new in the only case that
    /// calls this, and a receipt it already held for the same path would be the newer of the two —
    /// which is the one case the ledger's own `recordWrite` also resolves by keeping what it has been
    /// told last. Nothing is removed from `other`; it is discarded whole by its caller.
    public func adopt(_ other: EchoLedger) {
        let entries = other.receipts.withLock { $0 }
        guard !entries.isEmpty else { return }
        receipts.withLock { store in
            for (path, entry) in entries {
                store[path] = entry
            }
        }
    }

    private static func forget(_ store: inout [String: Entry], under path: String) {
        let prefix = path + "/"
        for key in Array(store.keys) where key.hasPrefix(prefix) {
            store.removeValue(forKey: key)
        }
    }

    // MARK: - Reading (tests, and the per-item footprint)

    /// How many receipts are outstanding — the supersession and consumption rules made assertable.
    public var outstandingReceipts: Int {
        receipts.withLock { $0.count }
    }

    public func receipt(at url: URL) -> Receipt? {
        receipt(atPath: Self.key(url))
    }

    public func receipt(atPath path: String) -> Receipt? {
        receipts.withLock { $0[path]?.receipt }
    }

    /// **Every receipt the ledger holds right now, with its heal mark — read, never consumed.**
    ///
    /// **The attribution surface** — the seam a provenance consumer harvests through, and it has to be
    /// a copy rather than a read at attribution time for an ordering reason worth stating here:
    /// receipts are *consumed* by the landing reload that classifies them ("one write, one echo"), and
    /// a consumer asks its question a debounce later — by which time the receipt for the user's own
    /// card edit is long gone, and reading the live ledger would attribute the user's own work to an
    /// external writer. So a consumer copies at the close of each write bracket, when a receipt
    /// describes a completed write and nothing has yet had a chance to retire it, and re-applies the
    /// satisfaction rule against disk itself.
    ///
    /// Nothing is retired here, which is what makes this safe to call on every bracket: the
    /// announcer's consumption still decides what speaks, and a harvested copy still decides what each
    /// batch is attributed to.
    ///
    /// **Kept with no live consumer** (`strategy/01-git-excision.md` ▸ What is kept): the harvest is
    /// the foundation the foreign-change journal will be built on, so the surface stays even though
    /// the git consumer that drove it has gone.
    // Mac-only because `HarvestedReceipt` is, and the phone target doesn't compile it. The recording
    // side above is what BoardWriter needs on every platform; this copy is Mac's alone.
    #if os(macOS)
    func outstandingEntries() -> [String: HarvestedReceipt] {
        receipts.withLock { store in
            store.mapValues { HarvestedReceipt(receipt: $0.receipt, isHeal: $0.isHeal) }
        }
    }
    #endif

    /// Whether the receipt at this path is heal-marked — `false` for a path with no receipt at all,
    /// which is the same shrug every other read here gives an unknown path.
    public func isHeal(at url: URL) -> Bool {
        isHeal(atPath: Self.key(url))
    }

    public func isHeal(atPath path: String) -> Bool {
        receipts.withLock { $0[path]?.isHeal ?? false }
    }

    /// **Which comments under one card the app itself just wrote** — the receipts, read through
    /// `CommentPath.classify`, and **retired on the way out**.
    ///
    /// This is the comment half of "app-mediated echoes never announce" (10-accessibility.md ▸ Live
    /// board announcements), and it has to be a separate read because the board reload cannot do the
    /// job: comments are outside the snapshot, so `verdicts(from:to:diff:includingTrash:)` has no two
    /// pictures to compare and `Footprint.observations` deliberately skips comment paths (see its
    /// note). What *does* have two pictures is the card window, which re-reads its thread on every
    /// landed reload — so it asks this, diffs its thread, and speaks only about the changes nobody
    /// here vouched for (`CardComments.reload`).
    ///
    /// ### Why classification rather than a prefix test
    ///
    /// Because the answer has to distinguish three homes under one prefix, and `CommentPath` is the
    /// app's one reader of that distinction (01-storage-format.md ▸ Enhanced schema's path shape): a
    /// receipt under `comments/<uuid>/` vouches for a posted comment, one under `comments/.trash/<uuid>/`
    /// vouches for a *delete* of that comment — which is the same identity disappearing from the thread
    /// and must be just as silent — and one under `comments/.draft/` vouches for nothing in the thread
    /// at all, because a draft is not in it. A `hasPrefix` would fold all three together.
    ///
    /// ### Retired, not merely read
    ///
    /// Consumption is what keeps the rule "one write, one echo": the receipts are removed, so a second
    /// reload observing the same thread finds nothing vouching for it and would speak — which is
    /// correct, because by then the change is a second change. It is also what stops the ledger growing
    /// a receipt per comment write for the life of a session; nothing else ever collects them.
    ///
    /// - Parameters:
    ///   - cardFolder: the card's folder on disk. Receipts are keyed by absolute path, so this is what
    ///     the sweep is rooted at.
    ///   - cardPath: the same card's path relative to the board root (`<lane>/<card>`), which is the
    ///     spelling `CommentPath.classify` reads. The two are handed in together rather than derived
    ///     from each other because the store already holds both (`BoardStore.commentSubject`).
    /// - Returns: the identities of the posted and just-deleted comments the app wrote.
    // Mac-only like the harvest above, and for the same shape of reason: the classification
    // here speaks `CommentPath` (LiveStore's comment-thread vocabulary), and its one caller is
    // the Mac store's comment reload. The phone's writers stamp comment receipts identically;
    // nothing on the phone retires them yet.
    //
    // `STORAGE_ONLY` is that same gate one axis over — see the verdicts section below, which states
    // it in full.
    #if os(macOS) && !STORAGE_ONLY
    func vouchedComments(inCard cardFolder: URL, cardPath: String) -> Set<ItemID> {
        let root = Self.key(cardFolder)
        let paths = receiptPaths(under: root)
        guard !paths.isEmpty else { return [] }

        var vouched: Set<ItemID> = []
        var consumed: [String] = []
        for path in paths {
            let relative = cardPath + path.dropFirst(root.count)
            guard let comment = CommentPath.classify(relative) else { continue }
            consumed.append(path)
            if let id = comment.id { vouched.insert(id) }
        }
        guard !consumed.isEmpty else { return [] }
        receipts.withLock { store in
            for path in consumed {
                // A move pair is one fact under two keys, and both of a comment's ends are under this
                // card — so removing the observed key and then the pair's own two ends retires it once
                // and leaves nothing dangling at the other end.
                if case let .move(from, to) = store[path]?.receipt {
                    store.removeValue(forKey: from)
                    store.removeValue(forKey: to)
                }
                store.removeValue(forKey: path)
            }
        }
        return vouched
    }
    #endif

    /// Every path the ledger holds a receipt for strictly *below* `folder`.
    ///
    /// Only a **card** may ask this. A lane's subtree is its cards' business and the board root's is
    /// the whole board's, so a prefix sweep at either level would consume receipts belonging to
    /// items that were never classified — see `Footprint.observations`.
    func receiptPaths(under folder: String) -> [String] {
        let prefix = folder + "/"
        return receipts.withLock { Array($0.keys.filter { $0.hasPrefix(prefix) }) }
    }

    // MARK: - Classification

    /// The per-file rule, over one item's footprint: **every receipt the ledger holds for these
    /// paths must still match what the reload observed there**, and there must be at least one.
    ///
    /// - No receipt anywhere in the footprint → `.foreign`. That is the launch-catch-up doctrine and
    ///   the reconciling reload's whole story: files changed during a blind window carry no
    ///   receipts, and the app never vouches for changes it didn't witness.
    /// - All held receipts satisfied → `.appMediated`, and they are **consumed** — one write, one
    ///   echo, and a second reload observing the same item finds nothing vouching for it.
    /// - Any held receipt unsatisfied → `.foreign`, and nothing is consumed (see the type's note).
    ///
    /// Paths in `observations` that the ledger knows nothing about are simply not consulted: a
    /// pasted card's copied attachments have no receipts of their own, and demanding one for every
    /// byte under an item would make every copy foreign.
    public func classify(_ observations: [String: Observation]) -> Provenance {
        let held: [(path: String, receipt: Receipt)] = receipts.withLock { store in
            observations.keys.compactMap { path in store[path].map { (path, $0.receipt) } }
        }
        guard !held.isEmpty else { return .foreign }
        for entry in held {
            guard let observation = observations[entry.path],
                  entry.receipt.isSatisfied(at: entry.path, by: observation)
            else { return .foreign }
        }
        receipts.withLock { store in
            for entry in held {
                store.removeValue(forKey: entry.path)
                // A move is one fact under two keys; retiring one end retires the other.
                if case let .move(from, to) = entry.receipt {
                    store.removeValue(forKey: from)
                    store.removeValue(forKey: to)
                }
            }
        }
        return .appMediated
    }
}

// MARK: - Satisfaction

extension EchoLedger.Receipt {

    /// Whether what the reload saw at `path` is what this receipt predicted.
    ///
    /// `path` is a parameter because a move's answer depends on which end is being asked: the old
    /// path must be empty and the new one must not.
    func isSatisfied(at path: String, by observation: EchoLedger.Observation) -> Bool {
        switch self {
        case let .content(hash):
            switch observation {
            case let .content(actual):
                // **Final content decides.** Byte-identical is app-mediated however it got there;
                // one byte different is foreign however it started.
                return actual == hash
            case .present:
                // Bytes the reload never read (an attachment): arrival is the whole of what this
                // receipt can be checked against, and a foreign rewrite of an attachment's *bytes*
                // cannot be what made the card's rendered content change anyway.
                return true
            case .absent:
                return false
            }
        case .absence:
            return observation == .absent
        case let .move(from, to):
            if path == to { return observation != .absent }
            if path == from { return observation == .absent }
            return false
        }
    }
}

// MARK: - The reload's verdicts

// Consumer-side machinery, Mac-only by compilation: everything below speaks `BoardDiff` and
// `ItemPath` — the landing reload's vocabulary, which the phone target doesn't compile. The
// phone records receipts through the surface above (BoardWriter stamps identically on every
// platform); its metadata-query observer has no verdict surface yet, and when it grows one this
// gate is the seam it lands behind.
//
// **`STORAGE_ONLY` is the same seam on a second axis** (added 2026-08-09 with the Quick Look preview
// extension, project.yml ▸ KanbanQuickLook): `os(macOS)` says which *platform* compiles this, and
// that flag says which *layer* does. A target that wants the storage engine and nothing above it —
// a preview extension, and any importer or tool that follows — compiles `Kanban/Storage` whole,
// because the directory is one unit in practice (`FrontmatterDocument` → `IntegrityRules` →
// `BoardLoader`/`BoardWriter` → this file's recording surface). What it cannot compile is *this*
// section, which reaches back up into the live store; without the flag the whole of `Kanban/LiveStore`
// would have to come with it, which is the tail wagging a read-only preview.
//
// Nothing in the app or the phone defines it, so both are unchanged by construction: the flag only
// ever *subtracts*, and only from a target that has no caller for any of it.
#if os(macOS) && !STORAGE_ONLY

/// One landing reload's provenance answers, in the two shapes the announcer needs.
public struct EchoVerdicts: Sendable, Equatable {

    /// The snapshot diff narrowed to the changes **the ledger does not vouch for** — the digest's
    /// only input (10-accessibility.md ▸ Live board announcements: "app-mediated echoes never
    /// announce", on every reload origin).
    public var foreign = BoardDiff()

    /// Every identity whose observed change classified foreign, buckets or not. Wider than
    /// `foreign`'s four buckets on purpose: an item whose `modified` stamp an agent bumped changes
    /// no rendered field and lands in no bucket, but it is still a foreign change and still what
    /// makes the digest's bare "Board changed" backstop honest.
    public var foreignItems: Set<ItemID> = []

    public init() {}
}

extension EchoLedger {

    /// **Which of this reload's changes were the app's own** — the classifier the announcer runs on
    /// every origin.
    ///
    /// ### Files are reached through items, and why that is the honest mapping
    ///
    /// FSEvents is directory-granular and the watcher deliberately reports only *that* the tree
    /// changed (02-architecture.md), so "the observed changed files" is not something a reload is
    /// handed — it is something the two snapshots identify. An item whose folder, whose `index.md`
    /// bytes, or whose attachment listing differs between them is precisely a changed file (or set
    /// of them) that this reload observed, and the item is the unit every consumer downstream — the
    /// digest, the vanishing-focus sentence, the change narrator's message (`Kanban/Changes/`) —
    /// actually speaks in.
    ///
    /// The bytes come from the snapshot rather than from a second read of the disk:
    /// `FrontmatterDocument` owns a byte-identical round trip, so `document.serialized()` **is** the
    /// file the walk read, and hashing it compares the receipt against the content that produced the
    /// snapshot now landing. That keeps the whole classification a pure function of two values — no
    /// I/O on the reload seam, and no window in which the file could change between the walk and the
    /// verdict.
    ///
    /// ### Implied events are the container's, here as in the digest
    ///
    /// A card that arrived with a brand-new lane, or left with a deleted one, is not classified at
    /// all: `BoardDiff` already counts that as the lane's single event, and classifying the cards
    /// separately would let an app-mediated lane delete (whose receipts were swept with the folder)
    /// produce a board full of foreign-looking children.
    ///
    /// - Parameter includingTrash: the digest's universe (`BoardDiff.between(_:_:includingTrash:)`).
    ///   Trash items are not classified while the column is hidden, for the same reason they are not
    ///   diffed: they are not in the universe at all, so their churn cannot make the board speak.
    public func verdicts(
        from old: BoardModel,
        to new: BoardModel,
        diff: BoardDiff,
        includingTrash: Bool
    ) -> EchoVerdicts {
        let older = Self.footprints(of: old, includingTrash: includingTrash)
        let newer = Self.footprints(of: new, includingTrash: includingTrash)

        var verdicts = EchoVerdicts()

        for (id, footprint) in newer {
            if let was = older[id], was == footprint { continue }
            if older[id] == nil, footprint.arrivalIsImplied(by: diff) { continue }
            if classify(footprint.observations(present: true, in: self)) == .foreign {
                verdicts.foreignItems.insert(id)
            }
        }
        for (id, footprint) in older where newer[id] == nil {
            if footprint.departureIsImplied(by: diff) { continue }
            if classify(footprint.observations(present: false, in: self)) == .foreign {
                verdicts.foreignItems.insert(id)
            }
        }

        // The board root's own `index.md`, whose footprint is that one file: the lanes below it are
        // items in their own right and the `.trash/` beside it is theirs too, so a prefix sweep here
        // would consume the whole board's receipts on a board rename.
        var boardRootIsForeign = false
        if old.document != new.document {
            boardRootIsForeign = classify([
                Self.key(new.rootURL): .present,
                Self.key(new.rootURL.appendingPathComponent(BoardLoader.indexFileName)):
                    .content(hash: Self.hash(of: new.document.serialized()))
            ]) == .foreign
        }

        verdicts.foreign.cards = Self.narrow(diff.cards, to: verdicts.foreignItems)
        verdicts.foreign.lanes = Self.narrow(diff.lanes, to: verdicts.foreignItems)
        // **The backstop, rebuilt rather than copied.** `BoardDiff.boardChanged` is "anything at all
        // differs", which on a mixed reload would be true because of the app's own write. What it
        // has to mean here is "some foreign change happened that no bucket names", and the honest
        // reading of that is: any foreign-classified item at all. A reload the ledger vouches for
        // end to end leaves it `false`, which is the silence the ruling is about.
        verdicts.foreign.boardChanged = boardRootIsForeign || !verdicts.foreignItems.isEmpty
        return verdicts
    }

    private static func narrow(_ changes: BoardDiff.Changes, to foreign: Set<ItemID>) -> BoardDiff.Changes {
        var narrowed = BoardDiff.Changes()
        narrowed.added = changes.added.intersection(foreign)
        narrowed.edited = changes.edited.intersection(foreign)
        narrowed.moved = changes.moved.intersection(foreign)
        narrowed.deleted = changes.deleted.intersection(foreign)
        return narrowed
    }

    // MARK: Footprints

    /// One item's files, as the snapshot describes them.
    ///
    /// `document` rather than its serialization because the *comparison* runs over every item on
    /// every reload and `FrontmatterDocument` is already `Equatable`; the bytes are produced only
    /// for the handful of items that turned out to have changed.
    struct Footprint: Equatable {
        var folder: String
        var document: FrontmatterDocument
        var attachments: [String]
        /// The lane this card sits in, for the implied-events rule. `nil` for a lane and for a
        /// trash card, neither of which has a container that can arrive or depart.
        var homeLane: ItemID?
        /// Whether the item's subtree is its own — true for a card, whose folder holds only its
        /// `index.md` and its `attachments/`, false for a lane.
        var ownsItsSubtree: Bool

        func arrivalIsImplied(by diff: BoardDiff) -> Bool {
            guard let homeLane else { return false }
            return diff.lanes.added.contains(homeLane)
        }

        func departureIsImplied(by diff: BoardDiff) -> Bool {
            guard let homeLane else { return false }
            return diff.lanes.deleted.contains(homeLane)
        }

        /// What the reload observed at every path this item can hold a receipt for.
        ///
        /// - The folder itself: present, or gone.
        /// - Its `index.md`: the hash of the bytes the walk read, or gone.
        /// - A card's remaining receipts, resolved by the snapshot's attachment listing — a name the
        ///   card still lists is `.present`, anything else (a removed attachment, a loose file that
        ///   was relocated out) is `.absent`.
        ///
        /// **A card's `comments/` is not observable here, and is therefore not observed** (added with
        /// comments, 01-storage-format.md ▸ Enhanced schema: "the board snapshot never loads comment
        /// content"). Two snapshots say nothing whatever about a thread — not that it changed, not
        /// that it did not, not even that it exists — so a comment receipt has no observation to be
        /// checked against, and the naive reading (anything not an attachment is `.absent`) would
        /// declare every comment the app itself just wrote *unsatisfied*: the very next foreign edit to
        /// the card's own `index.md` would then classify the card foreign twice over, and the user's
        /// own title edit would classify foreign once. Comment receipts are the **card window's** to
        /// read and retire (`EchoLedger.vouchedComments(inCard:cardPath:)`), which is the one place
        /// that does re-read a thread and can therefore say what happened to it.
        func observations(present: Bool, in ledger: EchoLedger) -> [String: EchoLedger.Observation] {
            var observations: [String: EchoLedger.Observation] = [
                folder: present ? .present : .absent,
                folder + "/" + BoardLoader.indexFileName:
                    present ? .content(hash: EchoLedger.hash(of: document.serialized())) : .absent
            ]
            guard ownsItsSubtree else { return observations }
            let attachmentPrefix = folder + "/" + BoardWriter.attachmentsFolderName + "/"
            let commentPrefix = folder + "/" + IntegrityRules.commentsFolderName + "/"
            for path in ledger.receiptPaths(under: folder) where observations[path] == nil {
                guard !path.hasPrefix(commentPrefix) else { continue }
                let name = path.hasPrefix(attachmentPrefix) ? String(path.dropFirst(attachmentPrefix.count)) : nil
                observations[path] = present && name.map(attachments.contains) == true ? .present : .absent
            }
            return observations
        }
    }

    /// Every item the digest's universe can see, keyed by identity — the same walk
    /// `BoardDiff.cardIndex` does, carrying files instead of rendered fields.
    static func footprints(of snapshot: BoardModel, includingTrash: Bool) -> [ItemID: Footprint] {
        var footprints: [ItemID: Footprint] = [:]
        let root = snapshot.rootURL
        for lane in snapshot.lanes {
            let laneFolder = ItemPath.lane(lane.id).folder(under: root)
            footprints[lane.id] = Footprint(
                folder: key(laneFolder),
                document: lane.document,
                attachments: [],
                homeLane: nil,
                ownsItsSubtree: false
            )
            for card in lane.cards {
                footprints[card.id] = Footprint(
                    folder: key(ItemPath.card(lane: lane.id, id: card.id).folder(under: root)),
                    document: card.document,
                    attachments: card.attachments,
                    homeLane: lane.id,
                    ownsItsSubtree: true
                )
            }
        }
        guard includingTrash else { return footprints }
        for card in snapshot.trash {
            footprints[card.id] = Footprint(
                folder: key(ItemPath.trashCard(card.id).folder(under: root)),
                document: card.document,
                attachments: card.attachments,
                homeLane: nil,
                ownsItsSubtree: true
            )
        }
        return footprints
    }
}

#endif
