import SwiftUI

// MARK: - The seam

/// **What the Labels section shows**, as pure functions over values — the section's rules, stated
/// where a test can hold them rather than as `if`s inside a view body (`CardDetails`' own posture).
enum CardLabelsPicker {

    /// How many suggestions the add field offers at once. Small on purpose: the sidebar is 26
    /// characters wide, the list pushes the sections below it down while it is open, and a field whose
    /// suggestion list is longer than the section it sits in has stopped being a hint.
    static let suggestionLimit = 6

    /// The labels the add field offers for `query`, in `universe`'s own order (frequency first —
    /// `LabelIndex`), never including one the card already carries.
    ///
    /// **An empty query offers the board's top labels rather than nothing.** Opening the field on a
    /// board that already has a vocabulary should show it — that is the difference between an
    /// autocomplete and a blank box, and it is the sidebar's answer to the same question the context
    /// menu answers with its twelve rows.
    ///
    /// Matching is a **case- and diacritic-insensitive substring**, which is board search's own rule
    /// (04-interactions.md § Search) and therefore the one match semantics this app has.
    static func suggestions(
        for query: String,
        universe: [String],
        existing: [String],
        limit: Int = suggestionLimit
    ) -> [String] {
        let query = query.trimmingCharacters(in: .whitespacesAndNewlines)
        let candidates = universe.filter { !CardLabels.contains($0, in: existing) }
        guard !query.isEmpty else { return Array(candidates.prefix(limit)) }
        let matches = candidates.filter {
            $0.range(of: query, options: [.caseInsensitive, .diacriticInsensitive]) != nil
        }
        return Array(matches.prefix(limit))
    }

    /// Whether committing `query` would **create** a label the board has never used — what the field's
    /// footer says out loud, so nobody mints a near-duplicate by accident.
    ///
    /// `false` for a name that trims to nothing (there is nothing to create) and for one the board
    /// already spells some way (that is an *apply*, and the board's spelling is what lands —
    /// `LabelCommand.resolved`).
    static func createsNewLabel(_ query: String, universe: [String]) -> Bool {
        guard let name = CardLabels.normalized(query) else { return false }
        return !CardLabels.contains(name, in: universe)
    }
}

// MARK: - The section

/// The sidebar's **Labels** section: the card's labels as removable rows, plus a quiet add field with
/// autocomplete against the board's used-labels universe and free-text creation
/// (`FrontmatterKeys.labels`, activated 2026-08-09 — the key's own doc comment has the reversal's
/// story; 05-card-window.md ▸ The attributes sidebar owes an amendment naming this section).
///
/// ### Where it sits, and why
///
/// **Second: after Style, before Details, above Attachments.** The stack reads Style · Labels ·
/// Details · Attachments, and each boundary is a decision:
///
/// - **After Style** rather than before it, because Style is the section every card has and the one a
///   user learns the sidebar by. Labels are the first *content* attribute, and content comes after
///   appearance in a stack whose top is the card's look.
/// - **Before Details**, which is the load-bearing one. Details is the section for keys the app does
///   **not** own ("the raw source outlet is the write path for frontmatter the app doesn't own"), and
///   `labels` just stopped being one of those. Putting a first-party, editable section below the
///   read-only overflow bin would read as an afterthought appended to the unknowns; putting it above
///   says what is true — the app owns this key now, and Details is what is left over.
/// - **Above Attachments**, which is settled and not mine to move: Attachments stays at the bottom of
///   the stack (reordered there 2026-08-09, Pipeline card 8f26b029) because it is the section that
///   grows without bound and advertises a drop surface.
///
/// The `VStack`'s child order *is* the Tab order and VoiceOver's reading order, so the paragraph above
/// is also the accessibility decision.
///
/// ### Rows, not chips
///
/// The scope allowed either. The sidebar is **26 characters wide** (`CardWindowMetrics.sidebarWidth`)
/// and every other section in it is a vertical stack of full-width rows — Details' key-over-value
/// pairs, Attachments' thumbnail rows. A flowing chip cloud in a column that narrow either wraps every
/// second label onto its own line (which is a row, drawn worse) or truncates names to four characters.
/// So: one label per row, a quiet leading tag glyph, the name, and a remove button at the trailing
/// edge — `AttachmentRow`'s silhouette, which is the idiom this column already teaches.
///
/// ### Present when empty, unlike Details
///
/// Details disappears on a card with no unknown keys because "there is nothing to teach" there. This
/// section stays, with a one-line hint, for **Attachments'** reason instead: it advertises an
/// affordance the user has to be able to find. A card window that showed no Labels section until the
/// card already had labels would leave the add affordance reachable only from the board's context
/// menu — which is a different window.
struct CardLabelsSection: View {

    let store: BoardStore
    let recents: LabelRecents
    let cardID: ItemID

    /// The card's labels as the last reload read them — passed in rather than looked up, so this
    /// section renders exactly what the window's own `Card` value says, like every other section here.
    let labels: [String]

    /// **This window's undo stack** (13-native-undo.md ▸ Rules ▸ two levels) — a label applied here is
    /// a gesture *issued in this window*, so its step joins the window's session and reaches board
    /// history only inside the coarse close step. `CardStyleSection.undo`'s reason verbatim.
    let undo: CardWindowUndo

    /// Whether the add field is showing. Local view state, `SymbolPicker`'s own posture: the window's
    /// card cannot vanish out from under its own window, and when it does the window goes with it
    /// (`CardWindowFate`), so there is no session to keep.
    @State private var isAdding = false

    /// What is typed in the add field.
    @State private var draft = ""

    @FocusState private var fieldFocused: Bool

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    /// The read-only lock and the board's inline-editing rule alike — `CardStyleSection`'s symbol row
    /// takes the same predicate, and 02-architecture.md's every-entry-point rule does not care which
    /// entry point.
    private var isEditable: Bool { store.acceptsBoardMutations }

    var body: some View {
        VStack(alignment: .leading, spacing: CardWindowMetrics.sidebarRowSpacing(bodyPointSize: pointSize)) {
            CardSidebarSectionHeader(title: "Labels") {
                addAffordance
            }

            if labels.isEmpty, !isAdding {
                emptyHint
            } else {
                rows
            }

            if isAdding {
                addField
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    // MARK: - Header

    /// The header's quiet `+` — `CardAttachmentsSection.addAffordance`'s twin, down to the glyph and
    /// its weight, because two sections one apart in the same column offering "add one of these"
    /// through two different controls would be the sidebar disagreeing with itself.
    ///
    /// It **toggles** the field rather than only opening it, so the same key the user reached for puts
    /// the field away again — Escape does too, but a control that can only open is a control that
    /// leaves litter.
    private var addAffordance: some View {
        Button {
            isAdding.toggle()
            if isAdding {
                fieldFocused = true
            } else {
                draft = ""
            }
        } label: {
            Image(systemName: "plus")
                .font(.caption.weight(.semibold))
                .foregroundStyle(.secondary)
        }
        .buttonStyle(.plain)
        .disabled(!isEditable)
        .help("Add Label")
        .accessibilityLabel("Add Label")
    }

    // MARK: - Empty

    /// One line, `CardAttachmentsSection.emptyHint`'s shape: it names the affordance that is on screen
    /// rather than a menu path, because unlike Add Attachment… there is no menu-bar row for this yet —
    /// the other way to reach it is the board's card context menu, which is a different window and no
    /// use to somebody reading this one.
    private var emptyHint: some View {
        Text("No labels. Press + to add one.")
            .font(.caption)
            .foregroundStyle(.secondary)
            .fixedSize(horizontal: false, vertical: true)
    }

    // MARK: - Rows

    private var rows: some View {
        VStack(alignment: .leading, spacing: 1) {
            ForEach(labels, id: \.self) { name in
                row(name)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    private func row(_ name: String) -> some View {
        HStack(spacing: 4) {
            Image(systemName: "tag")
                .font(.caption)
                .foregroundStyle(.secondary)
            Text(name)
                .font(.callout)
                .lineLimit(1)
                .truncationMode(.middle)
                .help(name)
            Spacer(minLength: 4)
            Button {
                LabelCommand.remove(name, fromCard: cardID, in: store, recents: recents, on: undo)
            } label: {
                Image(systemName: "xmark.circle.fill")
                    .font(.caption)
                    .foregroundStyle(.secondary)
            }
            .buttonStyle(.plain)
            .disabled(!isEditable)
            .help("Remove Label")
            .accessibilityLabel("Remove \(name)")
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .accessibilityElement(children: .contain)
        .accessibilityLabel(name)
    }

    // MARK: - Adding

    /// The add field and its suggestions.
    ///
    /// **Return commits, Escape cancels** — the inline editors' grammar (04-interactions.md ▸ Grammar),
    /// which is the one commit vocabulary this app has. Committing does *not* close the field: adding
    /// several labels in a row is the common case, so the field clears and stays, and the `+` (or
    /// Escape) is what puts it away.
    @ViewBuilder
    private var addField: some View {
        VStack(alignment: .leading, spacing: 2) {
            TextField("Label", text: $draft)
                .textFieldStyle(.roundedBorder)
                .font(.callout)
                .focused($fieldFocused)
                .disabled(!isEditable)
                .onSubmit { commit() }
                .onExitCommand {
                    draft = ""
                    isAdding = false
                }
                .accessibilityLabel("New Label")

            ForEach(suggestions, id: \.self) { name in
                Button {
                    apply(name)
                } label: {
                    Text(name)
                        .font(.caption)
                        .lineLimit(1)
                        .truncationMode(.middle)
                        .frame(maxWidth: .infinity, alignment: .leading)
                }
                .buttonStyle(.plain)
                .foregroundStyle(.secondary)
                .disabled(!isEditable)
            }

            if CardLabelsPicker.createsNewLabel(draft, universe: store.labelIndex.names) {
                // The only feedback that matters here: everything else in this field applies a label
                // that already exists somewhere on the board, and this one mints a new word for it.
                Text("Return creates “\(draft.trimmingCharacters(in: .whitespacesAndNewlines))”")
                    .font(.caption2)
                    .foregroundStyle(.secondary)
                    .fixedSize(horizontal: false, vertical: true)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }

    private var suggestions: [String] {
        CardLabelsPicker.suggestions(
            for: draft,
            universe: store.labelIndex.names,
            existing: labels
        )
    }

    /// Return's landing: apply what is typed, clear, stay open.
    private func commit() {
        guard CardLabels.normalized(draft) != nil else { return }
        apply(draft)
    }

    private func apply(_ name: String) {
        LabelCommand.add(name, onCard: cardID, in: store, recents: recents, on: undo)
        draft = ""
        fieldFocused = true
    }
}
