import Foundation

// MARK: - SearchFilter

/// The live search filter, as a pure predicate over a query and an item (`SearchFilterTests`) —
/// 04-interactions.md § Search's one sentence of behaviour and nothing else:
///
/// > live filter: cards whose title *and* body both miss the query animate out; case/diacritic-
/// > insensitive substring. Scope is **all card content the format makes meaningful** (re-ruled
/// > 2026-07-29): title + body today; **comment bodies join when comments ship** — via a search-owned
/// > transient comment index, never the snapshot. Attachment filenames stay unsearched.
///
/// ### Comments arrive as a set of ids, not as text
///
/// The predicate cannot read a comment: they are window-scoped and outside the snapshot
/// (01-storage-format.md ▸ Enhanced schema), so there is no body on a `Card` to fold. What the filter
/// takes instead is `commentMatches` — the cards whose threads the search's own transient index found
/// the query in (`CommentSearchIndex`) — and ORs it into the card clause. That keeps the design's
/// contract exactly as it was ("a card matches if any of its meaningful content matches") with **one**
/// spelling of the question, and keeps the I/O on the search's side of the boundary rather than in a
/// predicate every layout pass runs.
///

/// ### Why it is a value rather than a function
///
/// The needle is folded **once** per filter and matched against each item's folded haystack, so a
/// board-wide pass folds the query once rather than once per card. That is the only reason this is a
/// type: everything else about it is a free function's job, and the value stays `Equatable` so a
/// view can key an animated transaction on it as readily as on the raw query.
///
/// ### One predicate, every surface
///
/// "The filter is the single source of truth for 'what's on the board': layout, drop zones, marquee,
/// ranges, arrow nav, and lane count badges all read it." They read it *here* — the masonry through
/// `LaneView.renderedCards`, the ranges and Select All through `SelectionGrammar`'s order lists, the
/// trash through `SelectionGrammar`'s `trashCards`/`trashLanes`/`trashRows`, and the selection through
/// `TransientBoardState.constrainToSearch(in:)`. There is deliberately no second spelling of "does
/// this card match" anywhere, and no stored result set to go stale (`TransientBoardState`, kind 2).
///
/// ### What is *not* the predicate's business
///
/// **A live lane is never hidden by a card query.** 04 filters *cards*; a lane whose cards all miss
/// the query stays on the board showing an empty body and a `0` badge, because the width division is
/// layout and the filter is content.
///
/// **A trashed lane row is the one lane that filters** (03-board-ui.md § Trash, re-ruled 2026-07-29
/// — "The row matches the search filter by lane title only"), and the difference is exactly what an
/// opaque unit is: it is a row in a column, not a container the query can empty, so it either shows
/// or it does not. Hence one overload for it, deliberately title-only.
public struct SearchFilter: Sendable, Equatable {

    /// The query exactly as typed — kept so a caller can key a transaction or a test on it.
    public let query: String

    /// The query folded once. **Empty means the filter is off**, which is the whole of "empty query
    /// = everything visible": every `matches` below short-circuits to `true`.
    private let needle: String

    /// The cards whose **comment bodies** the query was found in — the search's transient index'
    /// contribution (`CommentSearchIndex.matchingCards`).
    ///
    /// Empty by default and empty whenever the sweep has not landed yet, which is why the first
    /// keystroke of a fresh query shows field-only matches for a moment and then refines. A stale set
    /// cannot survive a query change: the store rebuilds this value from the query *and* the index on
    /// every read (`BoardStore.searchFilter`), and the index recomputes its set on every keystroke.
    private let commentMatches: Set<ItemID>

    public init(query: String, commentMatches: Set<ItemID> = []) {
        self.query = query
        needle = Self.folded(query)
        self.commentMatches = commentMatches
    }

    /// No search — the default every threaded parameter carries, so a call site with no query to
    /// supply keeps reading exactly as it did before the filter existed.
    public static let inactive = SearchFilter(query: "")

    /// Whether a search is running at all.
    ///
    /// **Only the empty string is inactive**: whitespace is a legitimate substring (a user typing
    /// `fix ` mid-word means it), and trimming would be a rule the design does not state, applied to
    /// a live filter where the user sees the result of every keystroke immediately.
    public var isActive: Bool { !needle.isEmpty }

    // MARK: - The predicate

    /// **Title OR body**, which is the positive reading of 04's "cards whose title *and* body both
    /// miss the query animate out": a card is hidden only when neither field contains the query, so
    /// it is shown when either does.
    ///
    /// An absent title is not a miss to be excused — "Untitled" is a rendering, never a value
    /// (03-board-ui.md § Card face), so a query only ever matches text the file actually holds.
    public func matches(title: String?, body: String) -> Bool {
        guard isActive else { return true }
        if let title, Self.folded(title).contains(needle) { return true }
        return Self.folded(body).contains(needle)
    }

    /// A card — **title, body, or its comments** (04 § Search, re-ruled 2026-07-29).
    ///
    /// **Its attachment filenames are still not consulted**, which the re-ruling left standing
    /// ("attachment filenames stay unsearched"), and that is enforced here by construction rather than
    /// by remembering not to add `card.attachments` to the line above.
    ///
    /// The comment clause is a set membership rather than a text test for the reason the type's note
    /// gives: the text is not on the card, and the index that read it is the search's own.
    public func matches(_ card: Card) -> Bool {
        guard isActive else { return true }
        if matches(title: card.title.value, body: card.body) { return true }
        return commentMatches.contains(card.id)
    }

    /// A trashed lane row — **by title only** (03-board-ui.md § Trash, re-ruled 2026-07-29: "The row
    /// matches the search filter by lane title only").
    ///
    /// It is the opaque unit's own rule rather than an omission: the row's cards "are invisible to
    /// search and not individually addressable", and the lane's *body* is not on screen either, so
    /// matching anything the row does not show would hand the user a hit they cannot see.
    public func matches(_ lane: TrashedLane) -> Bool {
        matches(title: lane.title.value, body: "")
    }

    /// A trash row of either kind — the column's own predicate, dispatching to the two above so the
    /// merged order (`BoardModel.trashEntries`) filters exactly as its two halves do.
    public func matches(_ entry: TrashEntry) -> Bool {
        switch entry {
        case let .card(card): matches(card)
        case let .lane(lane): matches(lane)
        }
    }

    // MARK: - The visible universe

    /// Every id the filter leaves visible in `container` — **the universe
    /// `ItemReferenceSet.constrained(to:)` is handed** for 04's "hidden cards leave the selection"
    /// (`TransientBoardState.constrainToSearch(in:)`).
    ///
    /// It is shaped exactly like `ItemContainer.ids(in:)` and means the same thing one step
    /// narrower: that one answers "what does the board *have*", this one "what does the board
    /// *show*". The one difference is stated above and is not incidental: **every lane is in it**,
    /// because the filter hides cards, so a lane selection survives a query that empties its body.
    public func visibleIDs(in snapshot: BoardModel, container: ItemContainer) -> Set<ItemID> {
        var ids: Set<ItemID> = []
        switch container {
        case .board:
            for lane in snapshot.lanes {
                ids.insert(lane.id)
                for card in lane.cards where matches(card) {
                    ids.insert(card.id)
                }
            }
        case .trash:
            for card in snapshot.trash where matches(card) {
                ids.insert(card.id)
            }
            // A trash lane row *is* filtered, unlike a live lane: it is a row in a column rather than
            // a container the query empties, and it matches by title only (03-board-ui.md § Trash).
            for lane in snapshot.trashedLanes where matches(lane) {
                ids.insert(lane.id)
            }
        }
        return ids
    }

    // MARK: - Folding

    /// Case- and diacritic-insensitive, and **locale-stable**: `locale: nil` selects the canonical,
    /// locale-independent mapping rather than the user's, so a board does not filter differently in
    /// a Turkish locale than in an English one. The board is a file on disk, shared across machines
    /// and agents; a predicate whose answer depended on System Settings would be a different filter
    /// for every user of the same board.
    ///
    /// Both sides are folded with the same call, which is what makes a plain `contains` a correct
    /// insensitive substring test — folding one side only would compare a folded needle against an
    /// unfolded haystack and miss every accented match.
    private static func folded(_ text: String) -> String {
        text.folding(options: [.caseInsensitive, .diacriticInsensitive], locale: nil)
    }
}
