import Foundation
import Observation

// MARK: - What one card contributes to the sweep

/// One card the sweep will read a thread from: its identity, and where its folder is right now.
///
/// A value rather than a pair of parallel arrays because the sweep runs **off the main actor** and
/// the snapshot cannot travel with it — `BoardModel` is the board's whole tree, and handing it to a
/// detached task to walk twice would be doing the walk the design keeps at O(cards) all over again.
/// So the walk happens once, on the main actor, and what crosses the boundary is this: identity plus
/// a URL, per card, and nothing else.
public struct CommentSearchTarget: Sendable, Equatable {
    public let id: ItemID
    /// The card's folder — `<root>/<lane>/<card>`, or `<root>/.trash/<card>`. The thread hangs off it
    /// (`CommentThread.folder(inCard:)`).
    public let folder: URL

    public init(id: ItemID, folder: URL) {
        self.id = id
        self.folder = folder
    }
}

// MARK: - CommentSearchIndex

/// **Board search's transient comment index** — the search-owned sweep that lets a query reach comment
/// *bodies* without the board snapshot ever carrying one (04-interactions.md ▸ Search, re-ruled
/// 2026-07-29; 01-storage-format.md ▸ Enhanced schema):
///
/// > **comment bodies join when comments ship** — via a search-owned transient comment index, never
/// > the snapshot: the first live-query keystroke kicks an async sweep of `comments/*/index.md` bodies
/// > (`.draft` and `comments/.trash/` excluded), kept fresh … while a query is active and discarded
/// > when it clears — the board walk stays O(cards), 01's window-scoped read untouched.
///
/// ### Why it is its own object and not a member of `TransientBoardState`
///
/// Because `TransientBoardState`'s charter forbids exactly the thing this is. Its kind 2 is "derived
/// state, stored as its inputs only — `searchQuery` is kept; its *result set* is deliberately absent
/// … a stored result set would be a second, staler answer to a question the snapshot can always
/// answer". This index is the **opposite** case: the snapshot cannot answer it at all, because comment
/// content is deliberately outside the snapshot, so there is nothing to re-derive from and the answer
/// has to be kept. Keeping it there would mean giving that type a freshness rule of its own — a sweep
/// to schedule, a generation to compare, a task to cancel — which is precisely the accretion it exists
/// to keep out. So the index lives here, beside `SearchFilter`, owned by the search: one `let` on
/// `BoardStore` (`commentIndex`), created with the store and dying with it, like `echoes` and `heals`.
///
/// ### What it holds, and what that costs
///
/// The index proper is `[ItemID: [String]]` — one card's posted comment bodies, kept **as a list**
/// rather than joined, so a query can never match across the seam between two comments. It exists only
/// while a query does: the first keystroke builds it, every later keystroke re-filters it in memory
/// (no I/O per keystroke — that is what makes it an index rather than a search), and clearing the
/// query throws it away. The honest cost is stated rather than hidden: while a query is active, every
/// comment body on the board is in memory. A board with a thousand comments of a paragraph each is a
/// few hundred kilobytes, and the alternative — re-reading the tree per keystroke — is the thing an
/// index is for.
///
/// ### Freshness is snapshot-generation-shaped, and that is an interim
///
/// 04 says "kept fresh by the same FSEvents stream while a query is active". The store has no
/// changed-path channel — `FolderWatcher` reports only *that* the tree changed (02-architecture.md),
/// and the reload seam turns that into two snapshots rather than a path list — so the freshness signal
/// available today is `BoardStore.landedReloads`: **while a query is active, a landed reload
/// re-sweeps**. That is coarser than the ruling asks for (it re-reads threads a reload may not have
/// touched) and it is bounded by the same thing that bounds the reload itself, the watcher's debounce.
///
/// The counter is the *walk's*, not the applied snapshot's, and it has to be: comments are outside the
/// snapshot, so a comment arriving leaves the model value-equal and a value-equal reload skips its
/// assignment (blessed 2026-07-31). Keyed on the applied counter, the index would sleep through the
/// one kind of change it exists to notice.
/// A changed-path channel would narrow it to the cards whose `comments/` actually moved; until one
/// exists, this is the accepted interim and is written down here rather than discovered later.
///
/// ### Asynchrony, and the first keystroke's honest lag
///
/// The sweep is I/O and never runs on the main actor. Until it lands, the filter sees whatever the
/// index held before — nothing at all on the first keystroke of a fresh query — so **the first
/// keystroke may briefly show field-only matches**, and comment matches join a moment later. That is
/// the accepted behaviour rather than a defect: the alternative is a synchronous tree read on the
/// keystroke path, which is the one thing 01's window-scoped rule exists to prevent. Results
/// *refine*; they never regress, because a landed sweep only ever adds the cards it found.
@MainActor
@Observable
public final class CommentSearchIndex {

    /// **The cards whose comments match the live query** — the index's whole public surface, and the
    /// set `BoardStore.searchFilter` hands to `SearchFilter` so that one predicate keeps answering
    /// "does this card match" for every surface on the board.
    ///
    /// Empty means "no comment match", which is also what it means with no query, with no index yet,
    /// and on a board with no comments — four states with one honest answer, so no consumer branches.
    public private(set) var matchingCards: Set<ItemID> = []

    /// The index proper: posted comment bodies per card, in the thread's own order. `nil` while there
    /// is no index at all — before the first sweep of a query, and after a clear.
    ///
    /// Not `@ObservationIgnored`: a view reading `matchingCards` must not also wake on the megabytes
    /// behind it, but nothing outside this type reads this at all, so its observability costs nothing
    /// and its privacy is the guard.
    private var bodies: [ItemID: [String]]?

    /// The query the current `matchingCards` was computed against.
    private var query = ""

    /// The snapshot generation `bodies` was swept at, and the one a sweep in flight is for. Together
    /// they are the whole freshness rule: an index at the current generation is fresh, one at an older
    /// generation is re-swept, and a sweep already in flight for this generation is not started twice.
    private var sweptGeneration: Int?
    private var sweepingGeneration: Int?

    /// The sweep in flight. Cancelled — never awaited — when a newer one supersedes it: a stale sweep's
    /// result would describe a tree that has already changed, and the landing guard drops it anyway.
    private var sweep: Task<Void, Never>?

    /// **What to do when a sweep lands and the visible universe therefore widened or narrowed** —
    /// filled in by `BoardStore` with the selection constraint (04-interactions.md ▸ Search's "hidden
    /// cards leave the selection").
    ///
    /// It is a seam rather than a call into the store because this type has no business knowing what a
    /// selection is: the refine is an event, and what it costs is the store's rule. `nil` in every test
    /// that drives the index alone.
    @ObservationIgnored
    public var onRefine: (() -> Void)?

    public init() {}

    // MARK: - The two things that happen to it

    /// **Every write to the query, and every landed reload under one** — the single funnel, so the
    /// activation, the re-filter and the re-sweep are one decision rather than three call sites
    /// agreeing.
    ///
    /// - An **inactive** query discards everything (below), which is 04's "discarded when it clears".
    /// - An index already at `generation` is simply **re-filtered** — no I/O, which is the whole point
    ///   of holding bodies rather than a result set.
    /// - Anything else starts a sweep and re-filters against whatever is in hand meanwhile, so the
    ///   board never blanks while a fresher answer is being read.
    public func update(query: String, generation: Int, targets: @autoclosure () -> [CommentSearchTarget]) {
        guard SearchFilter(query: query).isActive else {
            discard()
            return
        }
        self.query = query

        if sweptGeneration != generation, sweepingGeneration != generation {
            startSweep(generation: generation, targets: targets())
        }
        recompute()
    }

    /// **The query cleared** — the index is thrown away, the sweep in flight is cancelled, and the
    /// board goes back to being O(cards) with nothing held (04 ▸ Search: "discarded when it clears").
    ///
    /// Idempotent: `BoardStore.searchQuery`'s setter funnels every clear through here, including the
    /// ones that were already clear.
    public func discard() {
        sweep?.cancel()
        sweep = nil
        sweepingGeneration = nil
        sweptGeneration = nil
        bodies = nil
        query = ""
        matchingCards = []
    }

    // MARK: - The sweep

    private func startSweep(generation: Int, targets: [CommentSearchTarget]) {
        sweep?.cancel()
        sweepingGeneration = generation
        sweep = Task { [weak self] in
            // Detached, so the tree read runs off the main actor: this is a directory enumeration and
            // a frontmatter parse per comment, and it is happening on the keystroke path.
            let swept = await Task.detached(priority: .userInitiated) {
                CommentSearchIndex.sweep(targets)
            }.value
            guard let self, !Task.isCancelled else { return }
            self.land(swept, generation: generation)
        }
    }

    /// One sweep's result, if it is still the one being waited for.
    ///
    /// The guard is the stale-apply rule `BoardStore.apply` keeps for reloads, and for its reason: a
    /// result whose generation is no longer the one in flight describes a tree that has moved on, and
    /// installing it would make the index *less* fresh than the one it replaced.
    private func land(_ swept: [ItemID: [String]], generation: Int) {
        guard sweepingGeneration == generation else { return }
        sweepingGeneration = nil
        sweptGeneration = generation
        bodies = swept
        let before = matchingCards
        recompute()
        // Only a change is worth telling anyone about: a sweep that confirmed what the filter already
        // showed must not re-run the selection constraint, which is a board-sized set computation.
        guard matchingCards != before else { return }
        onRefine?()
    }

    /// **The sweep itself** — every target's posted comment bodies, read off disk.
    ///
    /// `nonisolated` and taking only `Sendable` values, because it runs on a detached task. It is total
    /// and silent: a card with no `comments/`, one whose thread is unreadable, and one holding nothing
    /// but strays all contribute nothing, and none of them is a defect this read has any business
    /// reporting — the thread's own loader is where comment defects are logged and healed
    /// (`CommentThread.load(inCard:path:)`), and a search sweep that logged a warning per stray on
    /// every keystroke's re-sweep would be a log nobody could read.
    nonisolated static func sweep(_ targets: [CommentSearchTarget]) -> [ItemID: [String]] {
        var index: [ItemID: [String]] = [:]
        for target in targets {
            if Task.isCancelled { return index }
            let bodies = CommentThread.searchableBodies(inCard: target.folder)
            guard !bodies.isEmpty else { continue }
            index[target.id] = bodies
        }
        return index
    }

    /// **Which cards the current query matches through their comments** — `SearchFilter`'s own
    /// predicate, borrowed rather than restated.
    ///
    /// That is not tidiness: 04 gives the query *one* matching rule (case- and diacritic-insensitive
    /// substring, folded locale-stably), and a second spelling of it here would let a board's comments
    /// filter by a different rule than its titles do. `matches(title:body:)` with no title is exactly
    /// "does this text contain the query", which is all a comment body has to answer.
    nonisolated static func matchingCards(in bodies: [ItemID: [String]], query: String) -> Set<ItemID> {
        let filter = SearchFilter(query: query)
        guard filter.isActive else { return [] }
        var matches: Set<ItemID> = []
        for (id, texts) in bodies where texts.contains(where: { filter.matches(title: nil, body: $0) }) {
            matches.insert(id)
        }
        return matches
    }

    private func recompute() {
        matchingCards = Self.matchingCards(in: bodies ?? [:], query: query)
    }

    // MARK: - The board's cards, as targets

    /// Every card whose thread the sweep reads — **the live board and the trash**, because a trashed
    /// card carries its `comments/` (01-storage-format.md ▸ Enhanced schema) and the trash column's
    /// rows are filtered by the same predicate as the board's.
    ///
    /// One walk, on the main actor, producing values the sweep can carry (see `CommentSearchTarget`).
    /// Trashed *lanes* have no thread of their own — the row is an opaque unit matched by title alone
    /// (`SearchFilter.matches(_ lane:)`) — so they contribute nothing here.
    public nonisolated static func targets(in snapshot: BoardModel) -> [CommentSearchTarget] {
        let root = snapshot.rootURL
        var targets: [CommentSearchTarget] = []
        for lane in snapshot.lanes {
            for card in lane.cards {
                targets.append(CommentSearchTarget(
                    id: card.id,
                    folder: ItemPath.card(lane: lane.id, id: card.id).folder(under: root)
                ))
            }
        }
        for card in snapshot.trash {
            targets.append(CommentSearchTarget(
                id: card.id,
                folder: ItemPath.trashCard(card.id).folder(under: root)
            ))
        }
        return targets
    }

    // MARK: - Tests

    /// Suspends until the sweep in flight has landed — the index's own `awaitQuiescence`, and the only
    /// way a suite can assert about an answer that arrives a task later.
    public func awaitSweep() async {
        await sweep?.value
    }
}
