import AppKit
import Observation
import SwiftUI

// MARK: - Where the card window's ⌘F goes

/// Which of the card window's text surfaces Edit ▸ Find is about right now (05-card-window.md ▸
/// Preview):
///
/// > **Edit ▸ Find (⌘F) is find-in-text here** — the standard find bar over the focused surface
/// > (Preview's selectable text, the Edit editor, raw source — **and the comments pane, where it
/// > searches the whole rendered thread, `.draft` excluded; the composer and an inline comment edit
/// > are their own focused text surfaces with the editor's ordinary find**).
public enum CardWindowFindRoute: Sendable, Equatable {
    /// The body column's substrate — Preview, Edit, or the raw-source outlet. `NSTextFinder` over the
    /// one hosted scroll view, which is what ⌘F has always meant here (`CardBodyPresentation.findInText`).
    case body
    /// The comments pane's **whole rendered thread**, across rows (`CommentThreadFind`).
    case thread
    /// The composer, or an open inline edit session: "their own text surfaces with the editor's
    /// ordinary find" — a stock `NSTextView` find bar over that one editor.
    case authoring
}

/// Which surface *inside* the comments pane holds the keyboard, as the pane's own views report it.
///
/// Two cases and not three, because the routing only ever asks one question: is this a *reading*
/// surface (a rendered comment, where find means the thread) or an *authoring* one (the composer or an
/// inline session, where find means that editor). Which comment is being edited is the session's own
/// business and no concern of ⌘F's.
public enum CommentPaneFocus: Sendable, Equatable {
    case thread
    case authoring
}

/// **The routing rule, as a pure function** — extracted for every menu-validation rule's reason in
/// this codebase: a menu item's dispatch is otherwise only observable by driving the menu bar, and
/// "⌘F follows focus" is exactly the kind of clause that regresses into "⌘F always means the body".
public enum CardWindowFind {

    /// - Parameters:
    ///   - paneFocus: what the comments pane last reported (`CardComments.paneFocus`).
    ///   - isThreadFindShowing: whether the thread's find bar is already up. **It outranks an absent
    ///     pane focus**, because raising the bar takes the keyboard *out* of the thread and into the
    ///     bar's own field — so a second ⌘F, which every user expects to return to the search field,
    ///     would otherwise fall through to the body.
    ///   - hasBody: whether a body surface exists to find in at all (`CardBodyPresentation.findInText`).
    /// - Returns: the route, or `nil` when this window has nothing to find in — which is also the
    ///   window-less case, where Edit ▸ Find is the board's.
    public static func route(
        paneFocus: CommentPaneFocus?,
        isThreadFindShowing: Bool,
        hasBody: Bool
    ) -> CardWindowFindRoute? {
        if paneFocus == .authoring { return .authoring }
        if paneFocus == .thread || isThreadFindShowing { return .thread }
        return hasBody ? .body : nil
    }
}

// MARK: - The thread's rows, and what a query finds in them

/// One comment as the find sees it: an identity, and the **rendered** text of its body.
///
/// Rendered rather than raw Markdown, and that is the whole reason this type exists. 05 scopes the
/// find to "the whole *rendered* thread", the ranges it produces have to land in the text views the
/// rows actually draw (`CommentBodyView`, whose storage is the same renderer's output), and a user
/// searching for `code` should not match the backticks around it.
public struct CommentThreadRow: Sendable, Equatable {
    public let id: ItemID
    public let text: String

    public init(id: ItemID, text: String) {
        self.id = id
        self.text = text
    }
}

/// One hit: which comment, and where in its rendered text.
public struct CommentThreadMatch: Sendable, Equatable {
    public let comment: ItemID
    public let range: NSRange

    public init(comment: ItemID, range: NSRange) {
        self.comment = comment
        self.range = range
    }
}

/// **Finding a query in a thread** — pure, so the whole of what ⌘F *means* over the comments pane is
/// checkable without a window (05-card-window.md ▸ Preview).
public enum CommentThreadSearch {

    /// Every match, in reading order: rows in the order given, hits within a row left to right.
    ///
    /// ### The comparison is the board search's, not `NSTextFinder`'s default
    ///
    /// Case- **and diacritic-insensitive**, which is the rule 04-interactions.md fixes for the board's
    /// query and the one this app has therefore taught its users. It is done with
    /// `NSString.range(of:options:range:)` rather than by folding both sides, because folding can
    /// change a string's length — a decomposed character folds to fewer UTF-16 units — and a range
    /// measured in the folded string would highlight the wrong characters in the real one.
    ///
    /// An **empty query finds nothing** (rather than everything): the find bar opens empty, and a bar
    /// that reported "1 of 4000" before a key was pressed would be noise.
    public static func matches(in rows: [CommentThreadRow], query: String) -> [CommentThreadMatch] {
        guard !query.isEmpty else { return [] }
        var matches: [CommentThreadMatch] = []
        for row in rows {
            let text = row.text as NSString
            var searched = NSRange(location: 0, length: text.length)
            while searched.length > 0 {
                let found = text.range(
                    of: query,
                    options: [.caseInsensitive, .diacriticInsensitive],
                    range: searched
                )
                guard found.location != NSNotFound else { break }
                matches.append(CommentThreadMatch(comment: row.id, range: found))
                // Advance past the whole hit, so overlapping occurrences ("aa" in "aaa") are **one**
                // match — which is what `NSTextFinder` does, and therefore what the body's ⌘F in this
                // same window already does. `max(1, …)` keeps the loop advancing on principle; a
                // zero-length range cannot happen, because the query is non-empty.
                let next = found.location + max(1, found.length)
                searched = NSRange(location: next, length: max(0, text.length - next))
            }
        }
        return matches
    }

    /// **Stepping, with wraparound** — the find bar's own grammar (Edit ▸ Find Next / Find Previous,
    /// ⌘G / ⇧⌘G — 11-command-nexus.md), and the thing that makes a find spanning several text views
    /// read as *one* find: next from the last hit of one comment is the first hit of the next.
    ///
    /// `0` for an empty match list, which the caller never steps into anyway — stated so the function
    /// is total rather than trapping on a modulo by zero.
    public static func step(from index: Int, count: Int, forward: Bool) -> Int {
        guard count > 0 else { return 0 }
        let next = forward ? index + 1 : index - 1
        return ((next % count) + count) % count
    }

    /// The bar's counter — "3 of 12", or the not-found phrase. Pure for the reason every count line in
    /// this app is: the zero case is the one that renders wrong.
    public static func status(index: Int, count: Int, query: String) -> String {
        guard !query.isEmpty else { return "" }
        guard count > 0 else { return "No matches" }
        return "\(index + 1) of \(count)"
    }
}

// MARK: - CommentThreadFind

/// **The comments pane's find session** — the bar's state, the matches, and where the current one is
/// (05-card-window.md ▸ Preview's comments clause).
///
/// ### Why not `NSTextFinder`
///
/// Because there is no one text view to give it. The thread is a list of rows, each a real
/// `NSTextView` with its own storage (`CommentBodyView`, whose note explains why a scroll view per row
/// was rejected), and `NSTextFinder` finds within **one** client. The two ways out are a
/// discontiguous `NSTextFinderClient` vending the concatenated thread and mapping ranges back to rows,
/// or this: a small find bar over a **model** of the rendered thread, driving the rows' highlighting.
/// This is the smaller correct one — the rows already know how to draw a highlight, the model is a
/// pure function (`CommentThreadSearch`) rather than a protocol conformance whose contract is only
/// observable by using it, and it searches comments whose rows are not even mounted, which a client
/// built over live text views could not.
///
/// ### The honest limits, stated
///
/// - **It is the app's bar, not the system's.** Its grammar is deliberately the system's — a field,
///   a counter, previous/next, Done, ⌘G/⇧⌘G, Escape — but Replace, the search-options menu (whole
///   word, regular expression) and the find pasteboard are not there. The body's ⌘F is still the real
///   `NSTextFinder`, and so is the composer's, so the two surfaces that are *documents* keep the full
///   thing; the thread, which is a list, gets a list's find.
/// - **It searches bodies, not author lines.** The thread's content is what a reader means by "find
///   in this thread", and the author line is metadata rendered beside it, in a SwiftUI `Text` with no
///   range to highlight. Names are searchable where names are content — the board's query over titles.
/// - **The rows are rebuilt when the thread changes.** That is a render pass per reload *while the bar
///   is open*, which is why the rows are built lazily and dropped with the bar.
@MainActor
@Observable
public final class CommentThreadFind {

    /// Whether the bar is on screen. ⌘F raises it; Done and Escape take it down.
    public private(set) var isShowing = false

    /// The query, written live by the bar's field.
    public var query = "" {
        didSet {
            guard query != oldValue else { return }
            recompute(preservingPosition: false)
        }
    }

    /// Every hit in the thread, in reading order.
    public private(set) var matches: [CommentThreadMatch] = []

    /// Which hit is current — an index into `matches`, meaningless (and unread) when it is empty.
    public private(set) var index = 0

    /// Bumped to ask the bar's field for the keyboard. A **counter**, not a flag, for
    /// `CardComments.focusComposerRequests`' reason: two ⌘Fs in a row are two requests.
    public private(set) var focusRequests = 0

    /// The rendered thread the matches were computed against, rebuilt whenever the thread changes
    /// while the bar is open.
    private var rows: [CommentThreadRow] = []

    public init() {}

    // MARK: The bar

    /// **⌘F over a focused comments pane**: raise the bar if it is down, and put the keyboard in its
    /// field either way — the same "focus, not toggle" rule Edit ▸ Find keeps on the board
    /// (`FindCommand`), so a second press is a re-focus rather than a dismissal.
    public func invoke() {
        isShowing = true
        focusRequests += 1
    }

    /// Done, and Escape — the bar's two ways out. The query is kept: re-opening the bar on the search
    /// the user was just running is what every find bar on this platform does.
    public func dismiss() {
        isShowing = false
    }

    /// The thread, as the pane knows it — called when the bar opens and whenever the thread changes
    /// under an open bar.
    ///
    /// **The position survives a re-read**: an agent editing a comment three rows up must not move the
    /// user's place in their own search, so the index is clamped rather than reset. It *is* reset by a
    /// query change, which is a new search.
    public func setThread(_ comments: [Comment], pointSize: CGFloat) {
        let rows = comments.map { comment in
            CommentThreadRow(id: comment.id, text: Self.renderedText(of: comment.body, pointSize: pointSize))
        }
        guard rows != self.rows else { return }
        self.rows = rows
        recompute(preservingPosition: true)
    }

    /// The rendered plain text of one comment body — **the same renderer the row draws with**
    /// (`CommentBodyView` → `BodyMarkupRenderer`), so a range found here lands on the characters the
    /// user is looking at. A second way of turning Markdown into text would be a second answer to
    /// "what does this comment say", and the ranges would drift wherever the two disagreed.
    private static func renderedText(of body: String, pointSize: CGFloat) -> String {
        BodyMarkupRenderer.attributedString(
            for: BodyMarkup.parse(body),
            context: BodyMarkupRenderer.Context(pointSize: pointSize, cardFolder: nil)
        ).string
    }

    // MARK: Stepping

    /// Edit ▸ Find Next / Find Previous, and the bar's own chevrons. A no-op with nothing found.
    public func step(forward: Bool) {
        guard !matches.isEmpty else { return }
        index = CommentThreadSearch.step(from: index, count: matches.count, forward: forward)
    }

    /// The hit the pane scrolls to and draws in the current colour, or `nil` when there is none.
    ///
    /// **`nil` with the bar down**, which is what takes the highlighting off the thread when the user
    /// presses Done: the query survives a dismissal (so re-opening lands on the same search), and
    /// without this gate the matches would survive as marks on a thread nobody is searching.
    public var currentMatch: CommentThreadMatch? {
        guard isShowing, matches.indices.contains(index) else { return nil }
        return matches[index]
    }

    /// Every hit inside one comment — what a row highlights. `[]` for a row with none, which is the
    /// overwhelming majority and costs the row nothing, and `[]` for every row while the bar is down.
    public func matches(in comment: ItemID) -> [NSRange] {
        guard isShowing else { return [] }
        return matches.compactMap { $0.comment == comment ? $0.range : nil }
    }

    /// The bar's counter.
    public var status: String {
        CommentThreadSearch.status(index: index, count: matches.count, query: query)
    }

    private func recompute(preservingPosition: Bool) {
        matches = CommentThreadSearch.matches(in: rows, query: query)
        index = preservingPosition ? min(index, max(0, matches.count - 1)) : 0
    }
}

// MARK: - The bar

/// The comments pane's find bar — **the system find bar's grammar, in the app's own strip**
/// (see `CommentThreadFind` for why it is not an `NSTextFinder` bar).
///
/// A field, a counter, previous/next, Done: the four things a find bar on this platform has, in the
/// order it has them, so a user who has used the body's ⌘F recognises this one. Return steps forward
/// and Escape dismisses — the field's own two keys — while ⌘G/⇧⌘G are the menu's
/// (`FindSteppingCommands`), which is where the platform puts stepping that must work with the
/// keyboard anywhere in the window.
///
/// **⇧Return is deliberately not bound.** It is the obvious twin of Return, and binding it would mean
/// a window-wide key equivalent that outranks the composer's newline for as long as the bar is up —
/// a bar left open while the user goes back to typing would silently swallow their line breaks.
struct CommentFindBar: View {

    let find: CommentThreadFind

    @FocusState private var isFieldFocused: Bool

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    var body: some View {
        HStack(spacing: CardWindowMetrics.previewPadding(bodyPointSize: pointSize)) {
            field

            Text(find.status)
                .font(.caption)
                .foregroundStyle(.secondary)
                .monospacedDigit()
                // A live-updating count is a status, not a control: announced when it changes rather
                // than only when the rotor reaches it (10-accessibility.md's live-region posture).
                .accessibilityLabel(find.status)

            stepper(forward: false, symbol: "chevron.up", label: "Find Previous")
            stepper(forward: true, symbol: "chevron.down", label: "Find Next")

            Button("Done") { find.dismiss() }
                .controlSize(.small)
        }
        .padding(.horizontal, CardWindowMetrics.gutter(bodyPointSize: pointSize))
        .padding(.vertical, CardWindowMetrics.previewPadding(bodyPointSize: pointSize))
        .background(.background.secondary)
        .overlay(alignment: .bottom) { Divider() }
        // ⌘F's second half: the bar is raised by the menu item one update earlier, so the keyboard is
        // claimed here, where the field exists (`BoardSearchFieldView.updateNSView`'s rule).
        .onChange(of: find.focusRequests, initial: true) { _, _ in
            isFieldFocused = true
        }
    }

    private var field: some View {
        TextField("Find", text: Binding(get: { find.query }, set: { find.query = $0 }))
            .textFieldStyle(.roundedBorder)
            .controlSize(.small)
            .frame(width: CardWindowMetrics.findFieldWidth(bodyPointSize: pointSize))
            .focused($isFieldFocused)
            .onSubmit { find.step(forward: true) }
            // Escape dismisses the bar and nothing else — it never clears the query, which is the
            // find pasteboard's convention and the reason re-opening lands on the same search.
            .onExitCommand { find.dismiss() }
            .accessibilityLabel("Find in comments")
    }

    private func stepper(forward: Bool, symbol: String, label: String) -> some View {
        Button {
            find.step(forward: forward)
        } label: {
            Image(systemName: symbol)
                .font(.caption.weight(.semibold))
        }
        .buttonStyle(.plain)
        .disabled(find.matches.isEmpty)
        .help(label)
        .accessibilityLabel(label)
    }
}
