import SwiftUI
import SwiftData
import AppKit

/// Thin wrapper that owns the pagination state and hands the current page
/// size to `MainWindowContent`. Splitting the view here lets the inner view
/// rebuild its `@Query` with a fresh `fetchLimit` whenever `pageSize` bumps,
/// without resetting any of the row-level `@State` (hover, focused item).
struct MainWindowView: View {
    @EnvironmentObject var vm: ClipboardViewModel

    /// First-page fetch cap. Cold start renders at most this many rows from
    /// SwiftData — the rest are pulled in by the load-more sentinel as the
    /// user scrolls. Chosen so the initial viewport (~10–15 rows) is well-
    /// covered without ever materialising the whole history up front.
    @State private var pageSize: Int = 40
    /// Debounce so a chain of sentinel `.onAppear` calls (which can fire
    /// rapidly while the bottom rebalances) only triggers one bump.
    @State private var isBumpingPage: Bool = false

    private static let pageStep: Int = 40
    private static let pageCap: Int = 600

    var body: some View {
        // When the user is searching or filtering, results may live beyond the
        // current page; bypass pagination so they're never hidden.
        let filtering = !vm.searchText.isEmpty
            || !vm.activeTags.isEmpty
            || vm.selectedType != nil
            || vm.selectedSourceApp != nil
            || !vm.selectedGroupFilter.isAll
        let effective = filtering ? Self.pageCap : pageSize

        MainWindowContent(
            pageSize: effective,
            canLoadMore: !filtering && pageSize < Self.pageCap,
            onRequestMore: requestMore
        )
    }

    private func requestMore() {
        guard !isBumpingPage, pageSize < Self.pageCap else { return }
        isBumpingPage = true
        Task { @MainActor in
            try? await Task.sleep(nanoseconds: 80_000_000)
            pageSize = min(pageSize + Self.pageStep, Self.pageCap)
            isBumpingPage = false
        }
    }
}

struct MainWindowContent: View {
    @EnvironmentObject var vm: ClipboardViewModel
    @Query private var allItems: [ClipboardItem]
    @Query private var groups: [ClipboardGroup]
    @Environment(\.modelContext) private var modelContext
    @ObservedObject private var updater = UpdaterService.shared

    let pageSize: Int
    let canLoadMore: Bool
    let onRequestMore: () -> Void

    init(pageSize: Int, canLoadMore: Bool, onRequestMore: @escaping () -> Void) {
        self.pageSize = pageSize
        self.canLoadMore = canLoadMore
        self.onRequestMore = onRequestMore
        var descriptor = FetchDescriptor<ClipboardItem>(
            predicate: #Predicate { $0.deletedAt == nil },
            sortBy: [SortDescriptor(\.createdAt, order: .reverse)]
        )
        descriptor.fetchLimit = pageSize
        // Keep large image bytes and embedding vectors faulted until a visible
        // row or an active semantic search actually needs them.
        descriptor.propertiesToFetch = [
            \.id,
            \.type,
                \.content,
                \.fileURL,
                \.imageUTI,
                \.imageByteCount,
                \.imagePixelWidth,
                \.imagePixelHeight,
                \.imageStorageVersion,
                \.sourceApp,
            \.createdAt,
            \.isFavorite,
            \.isPinned,
            \.preview,
            \.deletedAt,
            \.tagsRaw,
            \.customTitle,
            \.groupIDsRaw,
        ]
        _allItems = Query(descriptor)

        let groupDescriptor = FetchDescriptor<ClipboardGroup>(
            sortBy: [
                SortDescriptor(\.sortOrder),
                SortDescriptor(\.createdAt)
            ]
        )
        _groups = Query(groupDescriptor)
    }

    @ObservedObject private var nav = AppNavigation.shared
    @ObservedObject private var stats = CopyStatsStore.shared
    @ObservedObject private var confirm = ConfirmationCenter.shared
    @ObservedObject private var ruleEditor = RuleEditorCenter.shared
    // Observe Content Protection so toggling the master switch or a category
    // re-renders the live list immediately (its `version` feeds each row's
    // `Equatable`), rather than waiting for the next incidental refresh.
    @ObservedObject private var contentProtection = ContentProtectionStore.shared

    @AppStorage("fdaOnboardingDismissed") private var fdaOnboardingDismissed = false
    @AppStorage("pinnedCollapsed") private var pinnedCollapsed = false
    @State private var isMergingSelection = false
    /// Non-nil when the user picked "Rename" from a row's context menu —
    /// drives the rename sheet at the root level so it survives row-view churn.
    @State private var renameTarget: ClipboardItem?
    @State private var barcodeScanTarget: ClipboardItem?
    @State private var qrPreviewTarget: ClipboardItem?
    @State private var showGroupManager = false
    @State private var groupEditorRequest: GroupEditorRequest?
    /// Paper-styled right-click menu for list rows, hosted in a borderless
    /// child panel so it can show a custom group-membership state and match the
    /// warm paper UI instead of the cold system `NSMenu`.
    @StateObject private var rowContextMenu = PaperContextMenuController()

    /// Header-stat caches. With pagination, `allItems` only contains the
    /// current page so these come from dedicated `fetchCount` / tag queries
    /// against the model context — that way the header always shows the true
    /// total even when the visible list is just the first few rows.
    @State private var totalRecordsCache: Int = 0
    @State private var favoritesCountCache: Int = 0
    @State private var allKnownTagsCache: [String] = []
    @State private var allKnownSourceAppsCache: [String] = []
    /// Bumped by ⌘F to pull keyboard focus into the toolbar's search field.
    @State private var searchFocusToken = 0

    /// One spring drives both the highlight glide and the scroll follow, in a
    /// single transaction, so they move as one. `interpolatingSpring` is the key
    /// to continuity: unlike `.spring` (which *retargets* and restarts its curve
    /// on every key-repeat event — the stutter), it's additive, so overlapping
    /// presses accumulate velocity into one unbroken glide. A small `bounce`
    /// leaves the soft "带点阻尼" settle without wobbling during fast repeats.
    private static let focusSpring: Animation = .interpolatingSpring(duration: 0.3, bounce: 0.16)

    private var filteredItems: [ClipboardItem] {
        vm.filteredItems(allItems)
    }

    var body: some View {
        ZStack(alignment: .top) {
            VisualEffectView(material: .underWindowBackground, blendingMode: .behindWindow)
                .ignoresSafeArea()

            backgroundDecoration
                .allowsHitTesting(false)

            // The card list stays mounted as the base layer; Settings / Stats /
            // Trash cross-fade in over it. Returning is then a pure reveal — we
            // no longer tear down and rebuild the (heavy) list view tree on
            // every back-navigation, which is what made the settings→list
            // transition hitch (and occasionally spin the beachball).
            listScreen

            if nav.screen != .list {
                secondaryScreen
                    .transition(.opacity)
            }

            if !fdaOnboardingDismissed {
                Color.black.opacity(0.45)
                    .ignoresSafeArea()
                    .onTapGesture {
                        withAnimation(.easeOut(duration: 0.2)) {
                            fdaOnboardingDismissed = true
                        }
                    }
                    .transition(.opacity)
                    .zIndex(2)

                FullDiskAccessOnboardingView {
                    withAnimation(.easeOut(duration: 0.2)) {
                        fdaOnboardingDismissed = true
                    }
                }
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .transition(.opacity.combined(with: .scale(scale: 0.96)))
                .zIndex(3)
            }

            // Single host for every confirm-before-delete dialog in the app.
            if let request = confirm.request {
                Color.black.opacity(0.45)
                    .ignoresSafeArea()
                    .onTapGesture { confirm.dismiss() }
                    .transition(.opacity)
                    .zIndex(4)

                ConfirmationDialogView(
                    request: request,
                    onConfirm: { confirm.runAndDismiss() },
                    onCancel: { confirm.dismiss() }
                )
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .transition(.opacity.combined(with: .scale(scale: 0.95)))
                .zIndex(5)
            }

            // Single host for the scripting rule editor — same spring-overlay
            // idiom as the confirm dialog above (not a system sheet), so it
            // scales in over the settings window with the app's motion.
            if let rule = ruleEditor.editing {
                Color.black.opacity(0.45)
                    .ignoresSafeArea()
                    .onTapGesture { ruleEditor.dismiss() }
                    .transition(.opacity)
                    .zIndex(6)

                RuleEditorPanel(
                    rule: rule,
                    onSave: { saved in
                        FilterSettingsStore.shared.upsertScriptingRule(saved)
                        ruleEditor.dismiss()
                    },
                    onCancel: { ruleEditor.dismiss() }
                )
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .transition(.opacity.combined(with: .scale(scale: 0.95)))
                .zIndex(7)
            }
        }
        .animation(.easeOut(duration: 0.22), value: nav.screen)
        .animation(.spring(response: 0.34, dampingFraction: 0.84), value: confirm.request?.id)
        .animation(.spring(response: 0.34, dampingFraction: 0.84), value: ruleEditor.editing?.id)
        .animation(.easeOut(duration: 0.22), value: fdaOnboardingDismissed)
        .onAppear {
            refreshDerivedCaches()
        }
        .task {
            // Historical OCR + embedding repair is maintenance work, not part
            // of first paint. Run it sequentially after launch settles; the
            // task is cancelled automatically if this window disappears.
            try? await Task.sleep(nanoseconds: 3_000_000_000)
            guard !Task.isCancelled else { return }
            await vm.backfillOCR(context: modelContext)
            guard !Task.isCancelled else { return }
            await vm.backfillEmbeddings(context: modelContext)
        }
        .onChange(of: allItems.count) { _, _ in
            refreshDerivedCaches()
        }
        .onChange(of: vm.tagCatalogVersion) { _, _ in
            refreshTagCatalog()
        }
        .onChange(of: vm.favoritesVersion) { _, _ in
            refreshFavoritesCount()
        }
        .sheet(isPresented: $vm.showExportPanel) {
            ExportPanelView {
                vm.showExportPanel = false
            }
        }
        .sheet(item: $renameTarget) { item in
            RenameClipSheet(
                initialTitle: item.effectiveCustomTitle ?? "",
                fallback: defaultDisplayTitle(for: item),
                onCommit: { newTitle in
                    vm.rename(item, to: newTitle, context: modelContext)
                    renameTarget = nil
                },
                onCancel: { renameTarget = nil }
            )
        }
        .sheet(item: $barcodeScanTarget) { item in
            BarcodeResultView(item: item, onClose: { barcodeScanTarget = nil })
        }
        .sheet(item: $qrPreviewTarget) { item in
            TextQRCodePreviewView(item: item, onClose: { qrPreviewTarget = nil })
        }
        .sheet(isPresented: $showGroupManager) {
            GroupManagementSheet(onClose: { showGroupManager = false })
        }
        .sheet(item: $groupEditorRequest) { request in
            GroupEditorSheet(
                title: request.title,
                initialName: request.initialName,
                actionTitle: request.actionTitle,
                onCommit: { name in
                    handleGroupEditorCommit(request, name: name)
                    groupEditorRequest = nil
                },
                onCancel: { groupEditorRequest = nil }
            )
        }
        .sheet(isPresented: $vm.showSnippetEditor) {
            SnippetEditorView(
                onSave: { content, type, pinned in
                    vm.createSnippet(
                        content: content,
                        type: type,
                        pinned: pinned,
                        context: modelContext
                    )
                    vm.showSnippetEditor = false
                    ToastCenter.shared.show(
                        pinned ? L("snippet.savedAndPinned") : L("snippet.saved"),
                        systemImage: "square.and.pencil",
                        tint: .appAccent
                    )
                },
                onCancel: { vm.showSnippetEditor = false }
            )
        }
    }

    /// Single, restrained accent halo in the upper-left — avoids the
    /// overlapping multi-gradient look that reads as generic.
    ///
    /// `equatable` lets SwiftUI bail out of re-laying-out this layer when the
    /// parent body re-runs (which it does on every keystroke / scope toggle).
    private var backgroundDecoration: some View {
        BackgroundHaloView()
    }

    /// Settings / Stats / Trash, each layered over a private copy of the shared
    /// backdrop (blur + accent halo). The `.behindWindow` material samples the
    /// desktop, not the in-window content beneath it, so this fully occludes the
    /// list that stays mounted behind — letting us keep the list warm without it
    /// bleeding through the panel.
    @ViewBuilder
    private var secondaryScreen: some View {
        ZStack(alignment: .top) {
            VisualEffectView(material: .underWindowBackground, blendingMode: .behindWindow)
                .ignoresSafeArea()

            backgroundDecoration
                .allowsHitTesting(false)

            switch nav.screen {
            case .settings: SettingsPanelView()
            case .stats:    StatsPanelView()
            case .trash:    TrashPanelView()
            case .list:     EmptyView()
            }
        }
    }

    /// Refresh the cached aggregates by querying the model context directly.
    /// Required since `allItems` is now a *paged* slice — counting/iterating
    /// it would underreport once the user scrolls past the first batch. We
    /// hand off to lighter SwiftData APIs (`fetchCount` for headcounts, a
    /// predicate-filtered fetch for the tag catalog) so this stays fast even
    /// when the live history is at its 500-item cap.
    private func refreshDerivedCaches() {
        refreshTotalRecords()
        refreshFavoritesCount()
        refreshTagCatalog()
        refreshSourceAppCatalog()
    }

    private func refreshTotalRecords() {
        let descriptor = FetchDescriptor<ClipboardItem>(
            predicate: #Predicate { $0.deletedAt == nil }
        )
        totalRecordsCache = (try? modelContext.fetchCount(descriptor)) ?? 0
    }

    private func refreshFavoritesCount() {
        let descriptor = FetchDescriptor<ClipboardItem>(
            predicate: #Predicate { $0.deletedAt == nil && $0.isFavorite }
        )
        favoritesCountCache = (try? modelContext.fetchCount(descriptor)) ?? 0
    }

    private func refreshTagCatalog() {
        // Only items that actually carry tags participate, keeping this much
        // cheaper than a full history walk.
        var descriptor = FetchDescriptor<ClipboardItem>(
            predicate: #Predicate { $0.deletedAt == nil && $0.tagsRaw != nil }
        )
        descriptor.propertiesToFetch = [\.deletedAt, \.tagsRaw]
        if let tagged = try? modelContext.fetch(descriptor) {
            allKnownTagsCache = vm.allKnownTags(in: tagged)
        } else {
            allKnownTagsCache = []
        }
    }

    private func refreshSourceAppCatalog() {
        var descriptor = FetchDescriptor<ClipboardItem>(
            predicate: #Predicate { $0.deletedAt == nil && $0.sourceApp != "" }
        )
        descriptor.propertiesToFetch = [\.deletedAt, \.sourceApp]
        if let sourced = try? modelContext.fetch(descriptor) {
            let apps = vm.allKnownSourceApps(in: sourced)
            allKnownSourceAppsCache = apps
            if let selected = vm.selectedSourceApp, !apps.contains(selected) {
                vm.selectedSourceApp = nil
            }
        } else {
            allKnownSourceAppsCache = []
        }
    }

    private var listScreen: some View {
        // Compute the filtered list once per render — the empty check, the
        // selection bar, and the card list all consumed it independently
        // before, forcing 2–3 full filter passes per scope toggle.
        let items = filteredItems
        let split = splitItems(for: items)

        return VStack(spacing: 0) {
            header
            toolbar
                .background(
                    Rectangle()
                        .fill(.regularMaterial)
                        .overlay(alignment: .bottom) {
                            Rectangle()
                                .fill(.separator.opacity(0.5))
                                .frame(height: 0.5)
                        }
                )
                // Lift the toolbar (and the search field's suggestion panel,
                // which overlays downward into the list area) above the card
                // list so the dropdown isn't drawn behind the rows.
                .zIndex(1)

            if items.isEmpty {
                emptyState
            } else {
                ZStack(alignment: .bottom) {
                    cardList(split: split)
                    if vm.isSelectionMode {
                        selectionActionBar(for: items)
                            .padding(.horizontal, 16)
                            .padding(.bottom, 14)
                            .transition(.move(edge: .bottom).combined(with: .opacity))
                    }
                }
                .animation(.spring(response: 0.32, dampingFraction: 0.82), value: vm.isSelectionMode)
            }
        }
        // Sits behind everything else — captures Space/↑/↓/Return/⌫ when no
        // editable control owns focus, so QL preview, row navigation, copy,
        // and delete all work from the keyboard even though the actual list
        // rows are SwiftUI views. `navigableItems` (not the raw filtered list)
        // is the navigation order so "next row" always means the next *visible*
        // row — pinned-first, skipping a collapsed pinned section.
        .background {
            // The list now stays mounted behind Settings/Stats/Trash, so only
            // arm the key catcher on the list screen — a persistent catcher
            // would hold first-responder there and let Space/⌫/arrows act on the
            // hidden list (a stray ⌫ could even delete a clip).
            if nav.screen == .list {
                PreviewKeyCatcher(
                    items: { navigableItems },
                focusedID: { vm.focusedItemID },
                setFocused: { id in
                    // Animate just the focus move so the sliding highlight
                    // springs from the old row to the new one. The scroll-follow
                    // is a separate, layout-settled step (see `cardList`'s
                    // onChange): doing it here, synchronously inside the key
                    // event, raced the layout and stranded the list during fast
                    // key-repeat. Coherence is automatic anyway — the highlight
                    // tracks the row's real frame, so it glides with the scroll
                    // however the scroll is triggered.
                    withAnimation(Self.focusSpring) { vm.focusedItemID = id }
                },
                copyAction: { item in
                    vm.copyToClipboard(item)
                    ToastCenter.shared.show(L("common.copied"))
                },
                deleteAction: { item in
                    deleteFocusedAndAdvance(item)
                }
                )
                .frame(width: 0, height: 0)
                .allowsHitTesting(false)
            }
        }
        // Same thin top tint as Settings/Stats/Trash, layered over the shared
        // VisualEffect + accent halo so the list screen carries the identical
        // theme-colored gradient as every other screen.
        .background(
            VStack(spacing: 0) {
                LinearGradient(
                    colors: [
                        Color.appAccent.opacity(0.10),
                        Color.clear
                    ],
                    startPoint: .top,
                    endPoint: .bottom
                )
                .frame(height: 220)
                Spacer(minLength: 0)
            }
            .allowsHitTesting(false)
            .ignoresSafeArea(edges: .top)
        )
    }

    private func selectionActionBar(for items: [ClipboardItem]) -> some View {
        let selected = vm.orderedSelectedItems(items)
        let blockReason = vm.mergeBlockReason(selectedItems: selected)
        let canMerge = blockReason == nil
        let hasMixedTypes = hasMixedSelectedTypes(selected)
        let allSelected = !items.isEmpty && selected.count == items.count

        return HStack(spacing: 10) {
            Text(L("selection.selectedFormat", selected.count, items.count))
                .font(.system(size: 13, weight: .semibold))
                .monospacedDigit()
            if let reason = blockReason, !selected.isEmpty {
                Text("·").foregroundStyle(.tertiary)
                Text(reason)
                    .font(.system(size: 12))
                    .foregroundStyle(.secondary)
                    .lineLimit(1)
            }

            Spacer(minLength: 10)

            SelectionBarButton(
                systemName: allSelected ? "checkmark.circle.badge.xmark" : "checkmark.circle",
                title: allSelected ? L("selection.clear") : L("selection.selectAll")
            ) {
                withAnimation(.spring(response: 0.28, dampingFraction: 0.78)) {
                    if allSelected { vm.clearSelection() }
                    else { vm.selectAll(items) }
                }
            }
            SelectionBarButton(systemName: "arrow.triangle.2.circlepath", title: L("selection.invert")) {
                withAnimation(.spring(response: 0.28, dampingFraction: 0.78)) {
                    vm.invertSelection(items)
                }
            }
            SelectionMoveGroupButton(
                groups: groups.sortedForDisplay(),
                showsRemoveFromGroup: selected.contains { $0.isGrouped },
                isDisabled: selected.isEmpty || isMergingSelection,
                onMove: { group in
                    performMoveSelected(selected, to: group)
                },
                onCreateGroup: {
                    groupEditorRequest = .create(assigning: selected)
                }
            )

            Divider().frame(height: 18).opacity(0.5)

            Button {
                withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
                    vm.exitSelectionMode()
                }
            } label: {
                Text(L("common.cancel"))
                    .font(.system(size: 13, weight: .medium))
                    .padding(.horizontal, 14)
                    .padding(.vertical, 7)
            }
            .buttonStyle(.plain)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(.secondary.opacity(0.15))
            )
            .disabled(isMergingSelection)

            Button {
                performDeleteSelected(selected)
            } label: {
                HStack(spacing: 6) {
                    Image(systemName: "trash")
                    Text(L("selection.deleteCountFormat", selected.count))
                }
                    .font(.system(size: 13, weight: .semibold))
                    .padding(.horizontal, 14)
                    .padding(.vertical, 7)
                    .foregroundStyle(.white)
            }
            .buttonStyle(.plain)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(selected.isEmpty ? Color.appDanger.opacity(0.35) : Color.appDanger)
            )
            .disabled(selected.isEmpty || isMergingSelection)

            Button {
                performMerge(selected)
            } label: {
                HStack(spacing: 6) {
                    if isMergingSelection {
                        ProgressView()
                            .progressViewStyle(.circular)
                            .controlSize(.mini)
                            .tint(.white)
                            .frame(width: 13, height: 13)
                        Text(L("selection.merging"))
                    } else {
                        Image(systemName: "square.stack.3d.up.fill")
                        Text(L("selection.mergeCountFormat", selected.count))
                    }
                }
                    .font(.system(size: 13, weight: .semibold))
                    .padding(.horizontal, 14)
                    .padding(.vertical, 7)
                    .foregroundStyle(.white)
            }
            .buttonStyle(.plain)
            .background(
                RoundedRectangle(cornerRadius: 8)
                    .fill(canMerge && !isMergingSelection ? Color.appAccent : Color.appAccent.opacity(0.35))
            )
            .disabled((!canMerge && !hasMixedTypes) || isMergingSelection)
        }
        .padding(.horizontal, 14)
        .padding(.vertical, 10)
        .background(
            ZStack {
                RoundedRectangle(cornerRadius: 14, style: .continuous)
                    .fill(.thickMaterial)
                RoundedRectangle(cornerRadius: 14, style: .continuous)
                    .fill(
                        LinearGradient(
                            colors: [Color.white.opacity(0.06), Color.clear],
                            startPoint: .top,
                            endPoint: .bottom
                        )
                    )
            }
        )
        .overlay(
            RoundedRectangle(cornerRadius: 14, style: .continuous)
                .strokeBorder(.separator.opacity(0.35), lineWidth: 0.5)
        )
        .shadow(color: .black.opacity(0.18), radius: 18, y: 6)
    }

    /// Bulk-delete the current selection. Routes through the same shared
    /// confirm dialog as every other delete, then soft- or hard-deletes (per
    /// the trash setting) and leaves selection mode.
    private func performDeleteSelected(_ selected: [ClipboardItem]) {
        guard !selected.isEmpty, !isMergingSelection else { return }
        let count = selected.count
        ConfirmationCenter.shared.confirm(
            title: L("confirm.deleteSelected.titleFormat", count),
            message: FilterSettingsStore.shared.trashEnabled
                ? L("confirm.deleteItem.message")
                : L("confirm.permanent.message"),
            confirmLabel: L("common.delete"),
            icon: "trash"
        ) {
            withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
                vm.deleteSelected(selected, context: modelContext)
            }
            ToastCenter.shared.show(
                L("selection.deletedFormat", count),
                systemImage: "trash.fill",
                tint: .red
            )
        }
    }

    private func performMoveSelected(_ selected: [ClipboardItem], to group: ClipboardGroup?) {
        guard !selected.isEmpty, !isMergingSelection else { return }
        let count = selected.count
        withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
            vm.assign(selected, to: group, context: modelContext)
        }
        ToastCenter.shared.show(
            group.map { L("selection.movedToGroupFormat", count, $0.displayName) } ?? L("selection.removedFromGroupFormat", count),
            systemImage: group == nil ? "folder.badge.minus" : "folder.fill",
            tint: .appAccent
        )
    }

    private func performMerge(_ selected: [ClipboardItem]) {
        guard !isMergingSelection else { return }
        if hasMixedSelectedTypes(selected) {
            ToastCenter.shared.show(
                L("selection.requireSameType"),
                systemImage: "exclamationmark.triangle.fill",
                tint: .red
            )
            return
        }
        let selectedCount = selected.count
        isMergingSelection = true

        Task { @MainActor in
            await Task.yield()
            let result = withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
                vm.mergeSelected(selected, context: modelContext)
            }
            isMergingSelection = false

            guard result != nil else {
                ToastCenter.shared.show(
                    L("selection.mergeFailed"),
                    systemImage: "exclamationmark.triangle.fill",
                    tint: .red
                )
                return
            }
            let suffix = MergeSettingsStore.shared.deleteOriginals ? L("selection.mergedSuffix.deleted") : ""
            ToastCenter.shared.show(
                L("selection.mergedFormat", selectedCount) + suffix,
                systemImage: "square.stack.3d.up.fill",
                tint: .appAccent
            )
        }
    }

    private func hasMixedSelectedTypes(_ selected: [ClipboardItem]) -> Bool {
        Set(selected.map(\.itemType)).count > 1
    }

    private var header: some View {
        HStack(spacing: 14) {
            AppLogoMark(size: 36, shadowRadius: 3, shadowOpacity: 0.45)

            VStack(alignment: .leading, spacing: 4) {
                Text(L("main.title"))
                    .font(.system(size: 17, weight: .semibold))
                    .tracking(0.2)
                HStack(spacing: 8) {
                    HeaderStat(value: "\(totalRecordsCache)", label: L("main.stat.records"))
                    HeaderStatDivider()
                    HeaderStat(value: "\(favoritesCountCache)", label: L("main.stat.favorites"))
                    if stats.enabled {
                        HeaderStatDivider()
                        HeaderStat(value: "\(stats.todayCount())", label: L("main.stat.today"), tint: .appAccent)
                    }
                }
            }

            Spacer()

            // The requirement's "new version prompt": appears only when a
            // scheduled Sparkle check found an update; one click brings the
            // update flow into focus (no detour through Settings). Lives next
            // to the capture toggle so both status chips share one corner.
            if updater.updateAvailable {
                UpdateReminderPill()
                    .transition(.scale(scale: 0.86).combined(with: .opacity))
            }

            CaptureToggle(isPaused: $vm.isCapturePaused)
        }
        .padding(.horizontal, 18)
        .padding(.top, 16)
        .padding(.bottom, 12)
        .animation(.easeOut(duration: 0.18), value: updater.updateAvailable)
    }

    private var toolbar: some View {
        HStack(spacing: 12) {
            ScopeSegmentedControl(selected: $vm.selectedScope)

            ToolbarFilterButton(
                selectedType: $vm.selectedType,
                selectedSourceApp: $vm.selectedSourceApp,
                sourceApps: allKnownSourceAppsCache
            )

            GroupFilterMenu(
                groups: groups.sortedForDisplay(),
                selection: $vm.selectedGroupFilter,
                onManageGroups: { showGroupManager = true }
            )

            // Sort control — only meaningful inside 收藏, where the list no
            // longer has to stay strictly reverse-chronological.
            if vm.selectedScope == .favorites {
                PaperMenuPicker(
                    options: FavoritesSortOrder.allCases.map { PaperMenuOption($0, $0.displayName, icon: $0.icon) },
                    selection: $vm.favoritesSortOrder,
                    width: 120,
                    help: L("favorites.sort.help")
                )
            }

            ToolbarSearchField(
                text: $vm.searchText,
                mode: $vm.searchMode,
                activeTags: $vm.activeTags,
                availableTags: allKnownTagsCache,
                featureEnabled: vm.semanticFeatureEnabled,
                indexing: vm.isBackfillingEmbeddings,
                focusToken: searchFocusToken
            )
            .background(
                // Invisible ⌘F target: bumping the token asks the search
                // field to grab keyboard focus (see ToolbarSearchField).
                Button("") { searchFocusToken &+= 1 }
                    .keyboardShortcut("f", modifiers: .command)
                    .opacity(0)
                    .frame(width: 0, height: 0)
                    .accessibilityHidden(true)
            )

            ToolbarIconButton(systemName: "square.and.pencil", help: L("toolbar.newSnippet")) {
                vm.showSnippetEditor = true
            }
            .keyboardShortcut("n", modifiers: .command)

            ToolbarIconButton(
                systemName: vm.isSelectionMode ? "checkmark.circle.fill" : "checkmark.circle",
                help: vm.isSelectionMode ? L("toolbar.exitSelection") : L("toolbar.multiSelect")
            ) {
                withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
                    if vm.isSelectionMode {
                        vm.exitSelectionMode()
                    } else {
                        vm.enterSelectionMode()
                    }
                }
            }

            ToolbarIconButton(systemName: "trash", help: L("toolbar.trash")) {
                nav.showTrash()
            }

            ToolbarIconButton(systemName: "chart.bar.xaxis", help: L("toolbar.stats")) {
                nav.showStats()
            }

            ToolbarIconButton(systemName: "gearshape", help: L("toolbar.settings")) {
                nav.showSettings()
            }
            .keyboardShortcut(",", modifiers: .command)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 10)
    }

    private var emptyState: some View {
        VStack(spacing: 18) {
            Spacer()
            ZStack {
                Circle()
                    .stroke(Color.appAccent.opacity(0.15), lineWidth: 1)
                    .frame(width: 110, height: 110)
                Circle()
                    .stroke(Color.appAccent.opacity(0.08), lineWidth: 1)
                    .frame(width: 150, height: 150)
                Image(systemName: emptyStateIcon)
                    .font(.system(size: 30, weight: .light))
                    .foregroundStyle(Color.appAccent.opacity(0.85))
            }
            VStack(spacing: 6) {
                Text(emptyStateTitle)
                    .font(.system(size: 16, weight: .semibold))
                Text(emptyStateSubtitle)
                    .font(.system(size: 12))
                    .foregroundStyle(.secondary)
                    .multilineTextAlignment(.center)
            }
            Spacer()
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    /// Any active filter — a keyword query or a tag chip — means an empty list
    /// is a "no match" result, not just an empty scope.
    private var isFiltering: Bool {
        !vm.searchText.isEmpty
            || !vm.activeTags.isEmpty
            || vm.selectedType != nil
            || vm.selectedSourceApp != nil
            || !vm.selectedGroupFilter.isAll
    }

    private var emptyStateIcon: String {
        if isFiltering { return "magnifyingglass" }
        switch vm.selectedScope {
        case .all: return "tray"
        case .favorites: return "star"
        }
    }

    private var emptyStateTitle: String {
        if isFiltering { return L("main.empty.title.noMatch") }
        switch vm.selectedScope {
        case .all: return L("main.empty.title.all")
        case .favorites: return L("main.empty.title.favorites")
        }
    }

    private var emptyStateSubtitle: String {
        if isFiltering { return L("main.empty.subtitle.noMatch") }
        switch vm.selectedScope {
        case .all: return L("main.empty.subtitle.all")
        case .favorites: return L("main.empty.subtitle.favorites")
        }
    }

    /// Split filtered items into a pinned section + the rest only for the
    /// top-level all-data view. Group-specific lists omit pinned items at the
    /// filter layer, and favorites keeps a single flat list.
    private func splitItems(for items: [ClipboardItem]) -> (pinned: [ClipboardItem], others: [ClipboardItem]) {
        // Single partition pass; previous implementation walked the list
        // twice (filter pinned + filter not-pinned).
        var pinned: [ClipboardItem] = []
        var others: [ClipboardItem] = []
        pinned.reserveCapacity(items.count / 4)
        others.reserveCapacity(items.count)
        for item in items {
            if item.isPinned { pinned.append(item) } else { others.append(item) }
        }
        if vm.selectedScope == .all && vm.selectedGroupFilter.isAll {
            return (pinned, others)
        }
        return ([], pinned + others)
    }

    /// The list flattened in the exact order it's drawn on screen, for
    /// keyboard navigation: pinned rows first (when that section is expanded),
    /// then the rest. Arrow keys, Return, and ⌫ all walk this so the focused
    /// row matches what the user sees.
    private var navigableItems: [ClipboardItem] {
        let split = splitItems(for: filteredItems)
        guard !split.pinned.isEmpty else { return split.others }
        return pinnedCollapsed ? split.others : split.pinned + split.others
    }

    /// Copy used by both the row trash button and the context-menu delete:
    /// pops the shared confirm dialog, then soft-deletes (to trash) or
    /// hard-deletes depending on the trash setting.
    private func requestDeleteItem(_ item: ClipboardItem) {
        ConfirmationCenter.shared.confirm(
            title: L("confirm.deleteItem.title"),
            message: FilterSettingsStore.shared.trashEnabled
                ? L("confirm.deleteItem.message")
                : L("confirm.permanent.message"),
            confirmLabel: L("common.delete"),
            icon: "trash"
        ) {
            vm.deleteItem(item, context: modelContext)
            ToastCenter.shared.show(L("common.deleted"), systemImage: "trash.fill", tint: .red)
        }
    }

    /// Delete the focused row from the keyboard, then move focus to whichever
    /// neighbor slides into its slot — the following row, or the previous one
    /// if it was the last — so repeated ⌫ presses keep clearing without
    /// leaving focus stranded on a now-deleted item. Gated behind the same
    /// confirm dialog as every other delete; the neighbor is resolved at
    /// confirm time so the focus lands correctly even if the list shifted.
    private func deleteFocusedAndAdvance(_ item: ClipboardItem) {
        ConfirmationCenter.shared.confirm(
            title: L("confirm.deleteItem.title"),
            message: FilterSettingsStore.shared.trashEnabled
                ? L("confirm.deleteItem.message")
                : L("confirm.permanent.message"),
            confirmLabel: L("common.delete"),
            icon: "trash"
        ) {
            let list = navigableItems
            let idx = list.firstIndex(where: { $0.id == item.id })
            vm.deleteItem(item, context: modelContext)
            if let idx {
                if idx + 1 < list.count {
                    vm.focusedItemID = list[idx + 1].id
                } else if idx - 1 >= 0 {
                    vm.focusedItemID = list[idx - 1].id
                } else {
                    vm.focusedItemID = nil
                }
            }
            ToastCenter.shared.show(L("common.deleted"), systemImage: "trash.fill", tint: .red)
        }
    }

    private func cardList(split: (pinned: [ClipboardItem], others: [ClipboardItem])) -> some View {
        ScrollViewReader { proxy in
            ScrollView {
                LazyVStack(spacing: 8) {
                    if !split.pinned.isEmpty {
                        pinnedHeader(count: split.pinned.count)
                        if !pinnedCollapsed {
                            rows(for: split.pinned)
                        }
                    }
                    rows(for: split.others)
                    // Load-more sentinel: a near-invisible footer that, when it
                    // scrolls into view inside the LazyVStack, asks the parent to
                    // grow the page size. `.id(pageSize)` makes each new page
                    // produce a fresh sentinel so its one-shot `.onAppear` re-arms
                    // after every successful expansion.
                    if canLoadMore && allItems.count >= pageSize {
                        LoadMoreSentinel(onAppear: onRequestMore)
                            .id(pageSize)
                    }
                }
                .padding(.horizontal, 16)
                .padding(.top, 14)
                .padding(.bottom, vm.isSelectionMode ? 80 : 14)
            }
            .scrollContentBackground(.hidden)
            // Single sliding focus highlight, positioned from the focused row's
            // live frame. Because it tracks the real frame every layout pass, it
            // stays glued to the row through a scroll (matching the content's
            // speed exactly) and only the focus *change* is animated — see
            // `focusHighlight`.
            .overlayPreferenceValue(RowBoundsKey.self) { anchors in
                focusHighlight(anchors: anchors)
            }
            // Follow keyboard focus. Done here (after the state settles and the
            // row is laid out) rather than inside the key handler, so it keeps up
            // reliably during fast key-repeat — synchronous scrolling there got
            // dropped and left the list stranded. `anchor: nil` scrolls only the
            // minimum: nothing moves until the focused row would slide off the
            // top or bottom edge. The anchor-driven highlight tracks the row's
            // real frame, so it stays glued through the scroll at matching speed.
            .onChange(of: vm.focusedItemID) { _, id in
                guard let id else { return }
                withAnimation(Self.focusSpring) {
                    proxy.scrollTo(id, anchor: nil)
                }
            }
        }
    }

    @ViewBuilder
    private func rows(for items: [ClipboardItem]) -> some View {
        ForEach(items) { item in
            cardRow(for: item)
        }
    }

    /// The one keyboard-focus highlight, parked over the focused row via its
    /// published frame. Drawn only in browse mode — selection/merge mode keeps
    /// its static per-row checkmarks/tint since several rows can be active.
    @ViewBuilder
    private func focusHighlight(anchors: [UUID: Anchor<CGRect>]) -> some View {
        GeometryReader { geo in
            if !vm.isSelectionMode,
               let id = vm.focusedItemID,
               let anchor = anchors[id] {
                let rect = geo[anchor]
                RoundedRectangle(cornerRadius: 12, style: .continuous)
                    .fill(Color.appAccent.opacity(0.10))
                    .overlay(
                        RoundedRectangle(cornerRadius: 12, style: .continuous)
                            .strokeBorder(Color.appAccent, lineWidth: 1)
                    )
                    .frame(width: rect.width, height: rect.height)
                    .position(x: rect.midX, y: rect.midY)
            }
        }
        // Clip to the scroll viewport so the highlight gets cut at the top/bottom
        // edge exactly like the row content does. Without this it's an unclipped
        // overlay, so a focused row sitting at (or mid-scroll past) an edge drew
        // its highlight outside the list, over the toolbar.
        .clipped()
        // Purely decorative — never let it intercept scroll or row clicks.
        .allowsHitTesting(false)
    }

    /// Resolves the display names of the groups an item belongs to, in
    /// assignment order. Items reference groups by UUID, so we look them up
    /// against the live `groups` query; ids whose group has since been
    /// deleted are dropped so the row shows only live chips.
    private func groupNames(for item: ClipboardItem) -> [String] {
        let ids = item.groupIDs
        guard !ids.isEmpty else { return [] }
        let namesByID = Dictionary(
            groups.map { ($0.id, $0.displayName) },
            uniquingKeysWith: { first, _ in first }
        )
        return ids.compactMap { namesByID[$0] }
    }

    @ViewBuilder
    private func cardRow(for item: ClipboardItem) -> some View {
        ClipboardItemRow(
            item: item,
            groupNames: groupNames(for: item),
            isSelectionMode: vm.isSelectionMode,
            // In normal browsing mode the same "selected" affordance doubles
            // as the keyboard-focus marker for arrow-nav + Space preview.
            isSelected: vm.isSelectionMode ? vm.isSelected(item) : (vm.focusedItemID == item.id),
            protectionVersion: contentProtection.version,
            onCopy: {
                vm.copyToClipboard(item)
                ToastCenter.shared.show(L("common.copied"))
            },
            onDelete: { requestDeleteItem(item) },
            onToggleFavorite: {
                let willFavorite = !item.isFavorite
                vm.toggleFavorite(item)
                ToastCenter.shared.show(
                    willFavorite ? L("action.favorited") : L("action.unfavorited"),
                    systemImage: "star.fill",
                    tint: .yellow
                )
            },
            onTogglePin: {
                let willPin = !item.isPinned
                vm.togglePin(item)
                ToastCenter.shared.show(
                    willPin ? L("action.pinned") : L("action.unpinned"),
                    systemImage: "pin.fill",
                    tint: .orange
                )
            },
            onRevealInFinder: {
                if let url = item.resolvedFileURL {
                    NSWorkspace.shared.activateFileViewerSelecting([url])
                }
            },
            onOpenFile: {
                if let url = item.resolvedFileURL {
                    NSWorkspace.shared.open(url)
                }
            },
            onOpenURL: {
                openInBrowser(item.content)
            },
            onSaveImage: {
                ExportService.shared.exportItem(item)
            },
            onPreview: {
                showQuickLook(for: item)
            },
            onAddTag: { tag in
                vm.addTag(tag, to: item)
                try? modelContext.save()
            },
            onRemoveTag: { tag in
                vm.removeTag(tag, from: item)
                try? modelContext.save()
            },
            onBase64Encode: {
                if vm.copyBase64Encoded(item) {
                    ToastCenter.shared.show(L("action.base64Encoded"), systemImage: "doc.on.doc")
                }
            },
            onBase64Decode: {
                if vm.copyBase64Decoded(item) {
                    ToastCenter.shared.show(L("action.base64Decoded"), systemImage: "doc.on.doc")
                } else {
                    ToastCenter.shared.show(
                        L("action.base64DecodeFailed"),
                        systemImage: "exclamationmark.triangle.fill",
                        tint: .red
                    )
                }
            }
        )
        // Gate the heavy row body on its value inputs (id + selection) so a
        // focus move only re-renders the two rows that actually change, not
        // every visible row. Keeps arrow-key scrolling smooth — see the
        // `==` on ClipboardItemRow.
        .equatable()
        // Custom paper-styled right-click menu (see `openRowContextMenu`) in
        // place of the cold system `NSMenu`. The catcher only intercepts
        // right/control-clicks; every other event falls through so hover and
        // the tap gestures below stay intact.
        .overlay(
            RightClickCatcher { screenPoint, window in
                openRowContextMenu(for: item, at: screenPoint, in: window)
            }
        )
        // Mount only one tap gesture at a time. Having both a single- and a
        // double-tap on the same view makes SwiftUI delay the single tap
        // until it can rule out a second click — that's the lag we were
        // seeing on selection.
        // Single tap (non-selection mode) sets keyboard focus so Space and the
        // arrow keys know which row the user is "on". Kept as a simultaneous
        // gesture so it doesn't compete with the double-tap-to-copy below
        // (SwiftUI would otherwise add the click-disambiguation delay).
        .modifier(FocusTapModifier(enabled: !vm.isSelectionMode) {
            vm.focusedItemID = item.id
        })
        .gesture(
            vm.isSelectionMode
                ? TapGesture(count: 1).onEnded {
                    withAnimation(.spring(response: 0.22, dampingFraction: 0.7)) {
                        vm.toggleSelection(item)
                    }
                }
                : TapGesture(count: 2).onEnded {
                    vm.copyToClipboard(item)
                    ToastCenter.shared.show(L("common.copied"))
                }
        )
        // Publish this row's live frame so the list can park a single sliding
        // highlight on whichever row is focused (see `focusHighlight`). Driving
        // the highlight from the real frame is what keeps it glued to the row
        // during a scroll — it moves at exactly the content's speed because it
        // *is* the content's position, not a second animation racing it.
        .anchorPreference(key: RowBoundsKey.self, value: .bounds) { [item.id: $0] }
    }

    private func pinnedHeader(count: Int) -> some View {
        Button {
            withAnimation(.easeOut(duration: 0.18)) { pinnedCollapsed.toggle() }
        } label: {
            HStack(spacing: 8) {
                Image(systemName: "pin.fill")
                    .font(.system(size: 11, weight: .semibold))
                    .foregroundStyle(.orange)
                Text(L("main.pinnedCountFormat", count))
                    .font(.system(size: 12, weight: .semibold))
                    .foregroundStyle(.secondary)
                    .monospacedDigit()
                Spacer()
                Image(systemName: pinnedCollapsed ? "chevron.right" : "chevron.down")
                    .font(.system(size: 10, weight: .semibold))
                    .foregroundStyle(.tertiary)
            }
            .padding(.horizontal, 12)
            .padding(.vertical, 7)
            .background(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(.secondary.opacity(0.08))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .strokeBorder(.separator.opacity(0.25), lineWidth: 0.5)
            )
            .contentShape(RoundedRectangle(cornerRadius: 8))
        }
        .buttonStyle(.plain)
    }

    /// Open the paper-styled right-click menu for `item` at the cursor. Every
    /// terminal action closes the menu first; group toggles deliberately keep
    /// it open so the membership checkmarks update live as the user assigns
    /// several groups in one pass.
    private func openRowContextMenu(for item: ClipboardItem, at screenPoint: CGPoint, in window: NSWindow) {
        let dismissThen: (@escaping () -> Void) -> () -> Void = { action in
            { rowContextMenu.close(); action() }
        }

        rowContextMenu.open(at: screenPoint, in: window, width: 232) {
            ClipboardRowContextMenu(
                controller: rowContextMenu,
                item: item,
                groups: groups.sortedForDisplay(),
                onCopy: dismissThen {
                    vm.copyToClipboard(item)
                    ToastCenter.shared.show(L("common.copied"))
                },
                onCopyPlainText: dismissThen {
                    vm.copyAsPlainText(item)
                    ToastCenter.shared.show(L("common.copiedPlainText"))
                },
                onPreviewQRCode: dismissThen { qrPreviewTarget = item },
                onScanCodes: dismissThen { barcodeScanTarget = item },
                onBase64Encode: dismissThen {
                    if vm.copyBase64Encoded(item) {
                        ToastCenter.shared.show(L("action.base64Encoded"), systemImage: "doc.on.doc")
                    }
                },
                onBase64Decode: dismissThen {
                    if vm.copyBase64Decoded(item) {
                        ToastCenter.shared.show(L("action.base64Decoded"), systemImage: "doc.on.doc")
                    } else {
                        ToastCenter.shared.show(
                            L("action.base64DecodeFailed"),
                            systemImage: "exclamationmark.triangle.fill",
                            tint: .red
                        )
                    }
                },
                onRunRule: { rule in
                    rowContextMenu.close()
                    vm.runRuleManually(rule, on: item, context: modelContext)
                },
                onRename: dismissThen { renameTarget = item },
                onToggleGroup: { group in
                    let wasMember = item.isInGroup(group.id)
                    vm.toggleGroup(item, group: group, context: modelContext)
                    ToastCenter.shared.show(
                        wasMember
                            ? L("group.removedFromFormat", group.displayName)
                            : L("group.addedToFormat", group.displayName),
                        systemImage: wasMember ? "folder.badge.minus" : "folder.fill",
                        tint: .appAccent
                    )
                },
                onClearGroups: {
                    vm.clearGroups(item, context: modelContext)
                    ToastCenter.shared.show(L("group.removedFromGroup"), systemImage: "folder.badge.minus")
                },
                onNewGroup: dismissThen { groupEditorRequest = .create(assigning: item) },
                onOpenInBrowser: dismissThen { openInBrowser(item.content) },
                onReveal: dismissThen {
                    if let url = item.resolvedFileURL {
                        NSWorkspace.shared.activateFileViewerSelecting([url])
                    }
                },
                onOpenFile: dismissThen {
                    if let url = item.resolvedFileURL { NSWorkspace.shared.open(url) }
                },
                onOpenWith: dismissThen {
                    if let url = item.resolvedFileURL { FileOpener.openWithChooser(url: url) }
                },
                onToggleFavorite: dismissThen {
                    let willFavorite = !item.isFavorite
                    vm.toggleFavorite(item)
                    ToastCenter.shared.show(
                        willFavorite ? L("action.favorited") : L("action.unfavorited"),
                        systemImage: "star.fill",
                        tint: .yellow
                    )
                },
                onTogglePin: dismissThen {
                    let willPin = !item.isPinned
                    vm.togglePin(item)
                    ToastCenter.shared.show(
                        willPin ? L("action.pinned") : L("action.unpinned"),
                        systemImage: "pin.fill",
                        tint: .orange
                    )
                },
                onExport: dismissThen { ExportService.shared.exportItem(item) },
                onDelete: dismissThen { requestDeleteItem(item) }
            )
        }
    }

    /// Open the native QuickLook panel for `item`, using the current filtered
    /// list as the navigation stack so arrow keys move through neighbors.
    private func showQuickLook(for item: ClipboardItem) {
        let items = navigableItems
        let index = items.firstIndex(where: { $0.id == item.id }) ?? 0
        vm.focusedItemID = item.id
        QuickLookCoordinator.shared.preview(items: items, startingAt: index)
    }

    private func openInBrowser(_ raw: String) {
        let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !trimmed.isEmpty else { return }
        let candidate: String = {
            if trimmed.contains("://") { return trimmed }
            return "https://\(trimmed)"
        }()
        if let url = URL(string: candidate) {
            NSWorkspace.shared.open(url)
        }
    }

    /// Heuristic title used as the rename sheet's placeholder/fallback so
    /// users see what the row currently shows before they type anything.
    private func defaultDisplayTitle(for item: ClipboardItem) -> String {
        if let url = item.resolvedFileURL { return url.lastPathComponent }
        // Mask sensitive spans so the rename sheet's placeholder never reveals
        // the raw value (the user can still type any new title they want).
        let firstLine = item.redactedForDisplay(item.preview ?? item.content)
            .components(separatedBy: .newlines)
            .first ?? ""
        return firstLine.isEmpty ? item.itemType.displayName : firstLine
    }

    private func handleGroupEditorCommit(_ request: GroupEditorRequest, name: String) {
        switch request.kind {
        case .create(let assigning):
            guard let group = vm.createGroup(named: name, groups: groups, context: modelContext) else { return }
            if !assigning.isEmpty {
                vm.assign(assigning, to: group, context: modelContext)
                ToastCenter.shared.show(L("group.movedToFormat", group.displayName), systemImage: "folder.fill", tint: .appAccent)
            } else {
                vm.selectedGroupFilter = .group(group.id)
                ToastCenter.shared.show(L("group.createdFormat", group.displayName), systemImage: "folder.badge.plus", tint: .appAccent)
            }
        case .rename(let group):
            vm.renameGroup(group, to: name, context: modelContext)
            ToastCenter.shared.show(L("group.renamed"), systemImage: "pencil", tint: .appAccent)
        }
    }

}

// MARK: - Row context menu

/// Shared, observable group-membership set so the root menu and the flyout
/// submenu stay in lockstep: toggling a group in the submenu flips its check
/// instantly, even though the two live in separate hosted view trees.
@MainActor
final class PaperGroupMembership: ObservableObject {
    @Published var ids: Set<UUID>
    init(ids: Set<UUID>) { self.ids = ids }
    func toggle(_ id: UUID) {
        if ids.contains(id) { ids.remove(id) } else { ids.insert(id) }
    }
    func clear() { ids.removeAll() }
}

/// Paper-styled replacement for the list row's right-click `NSMenu`. Hosted in
/// a borderless child panel by `PaperContextMenuController`, so it lives in its
/// own SwiftUI tree — every dependency arrives as a value or closure rather
/// than through the environment.
///
/// Group assignment lives in a flyout secondary menu (`ClipboardGroupSubmenu`),
/// where each group carries a leading selected-state so "which groups is this
/// clip in" is legible at a glance — the gap the old system menu left.
private struct ClipboardRowContextMenu: View {
    let controller: PaperContextMenuController
    let item: ClipboardItem
    let groups: [ClipboardGroup]
    let onCopy: () -> Void
    let onCopyPlainText: () -> Void
    let onPreviewQRCode: () -> Void
    let onScanCodes: () -> Void
    let onBase64Encode: () -> Void
    let onBase64Decode: () -> Void
    let onRunRule: (ScriptingRule) -> Void
    let onRename: () -> Void
    let onToggleGroup: (ClipboardGroup) -> Void
    let onClearGroups: () -> Void
    let onNewGroup: () -> Void
    let onOpenInBrowser: () -> Void
    let onReveal: () -> Void
    let onOpenFile: () -> Void
    let onOpenWith: () -> Void
    let onToggleFavorite: () -> Void
    let onTogglePin: () -> Void
    let onExport: () -> Void
    let onDelete: () -> Void

    @StateObject private var membership: PaperGroupMembership
    @StateObject private var submenu = PaperSubmenuCoordinator()

    init(
        controller: PaperContextMenuController,
        item: ClipboardItem,
        groups: [ClipboardGroup],
        onCopy: @escaping () -> Void,
        onCopyPlainText: @escaping () -> Void,
        onPreviewQRCode: @escaping () -> Void,
        onScanCodes: @escaping () -> Void,
        onBase64Encode: @escaping () -> Void,
        onBase64Decode: @escaping () -> Void,
        onRunRule: @escaping (ScriptingRule) -> Void,
        onRename: @escaping () -> Void,
        onToggleGroup: @escaping (ClipboardGroup) -> Void,
        onClearGroups: @escaping () -> Void,
        onNewGroup: @escaping () -> Void,
        onOpenInBrowser: @escaping () -> Void,
        onReveal: @escaping () -> Void,
        onOpenFile: @escaping () -> Void,
        onOpenWith: @escaping () -> Void,
        onToggleFavorite: @escaping () -> Void,
        onTogglePin: @escaping () -> Void,
        onExport: @escaping () -> Void,
        onDelete: @escaping () -> Void
    ) {
        self.controller = controller
        self.item = item
        self.groups = groups
        self.onCopy = onCopy
        self.onCopyPlainText = onCopyPlainText
        self.onPreviewQRCode = onPreviewQRCode
        self.onScanCodes = onScanCodes
        self.onBase64Encode = onBase64Encode
        self.onBase64Decode = onBase64Decode
        self.onRunRule = onRunRule
        self.onRename = onRename
        self.onToggleGroup = onToggleGroup
        self.onClearGroups = onClearGroups
        self.onNewGroup = onNewGroup
        self.onOpenInBrowser = onOpenInBrowser
        self.onReveal = onReveal
        self.onOpenFile = onOpenFile
        self.onOpenWith = onOpenWith
        self.onToggleFavorite = onToggleFavorite
        self.onTogglePin = onTogglePin
        self.onExport = onExport
        self.onDelete = onDelete
        _membership = StateObject(
            wrappedValue: PaperGroupMembership(ids: Set(groups.map(\.id).filter(item.isInGroup)))
        )
    }

    private var hasFile: Bool { item.resolvedFileURL != nil }
    /// Enabled rules surfaced as manual "run rule" actions. Read at menu-build
    /// time (the menu is rebuilt on each open, so no reactive observation needed).
    private var enabledRules: [ScriptingRule] {
        FilterSettingsStore.shared.scriptingRules.filter(\.isEnabled)
    }
    private var canScanCodes: Bool {
        item.hasImagePayload
    }
    private var canPreviewQRCode: Bool {
        item.itemType == .text &&
        !item.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
    }
    private var canBase64Encode: Bool {
        guard let text = ClipboardViewModel.transformableText(of: item) else { return false }
        return !text.isEmpty
    }
    /// Offer decoding only when the content actually decodes to UTF-8 text,
    /// so the row never leads to a dead-end error for ordinary prose.
    private var canBase64Decode: Bool {
        guard let text = ClipboardViewModel.transformableText(of: item) else { return false }
        return ClipboardViewModel.base64Decoded(text) != nil
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 1) {
            PaperMenuActionRow(icon: "doc.on.doc", title: L("action.copy"), action: onCopy)
            PaperMenuActionRow(icon: "doc.plaintext", title: L("action.copyAsPlainText"), action: onCopyPlainText)
            if canPreviewQRCode {
                PaperMenuActionRow(icon: "qrcode", title: L("action.qrPreview"), action: onPreviewQRCode)
            }
            if canScanCodes {
                PaperMenuActionRow(icon: "qrcode.viewfinder", title: L("action.scanCodes"), action: onScanCodes)
            }
            if canBase64Encode {
                PaperMenuActionRow(
                    icon: "chevron.left.forwardslash.chevron.right",
                    title: L("action.base64Encode"),
                    action: onBase64Encode
                )
            }
            if canBase64Decode {
                PaperMenuActionRow(
                    icon: "abc",
                    title: L("action.base64Decode"),
                    action: onBase64Decode
                )
            }

            if !enabledRules.isEmpty {
                PaperMenuDivider()
                ForEach(enabledRules) { rule in
                    PaperMenuActionRow(
                        icon: "wand.and.stars",
                        title: L("action.runRule", rule.displayName),
                        action: { onRunRule(rule) }
                    )
                }
            }

            PaperMenuDivider()
            PaperMenuActionRow(icon: "pencil", title: L("action.rename"), action: onRename)
            // Group assignment opens a flyout secondary menu when groups exist;
            // with none yet, jump straight to creating one.
            if groups.isEmpty {
                PaperMenuActionRow(icon: "folder.badge.plus", title: L("group.newAndMove"), action: onNewGroup)
            } else {
                PaperSubmenuRow(icon: "folder", title: L("group.moveTo"), coordinator: submenu)
            }

            if item.itemType == .url {
                PaperMenuDivider()
                PaperMenuActionRow(icon: "safari", title: L("action.openInBrowser"), action: onOpenInBrowser)
            }

            if hasFile {
                PaperMenuDivider()
                PaperMenuActionRow(icon: "folder", title: L("action.revealInFinder"), action: onReveal)
                PaperMenuActionRow(icon: "arrow.up.forward.app", title: L("action.openFile"), action: onOpenFile)
                PaperMenuActionRow(icon: "app.badge", title: L("action.openWith"), action: onOpenWith)
            }

            PaperMenuDivider()
            PaperMenuActionRow(
                icon: item.isFavorite ? "star.slash" : "star",
                title: item.isFavorite ? L("action.unfavorite") : L("action.favorite"),
                action: onToggleFavorite
            )
            PaperMenuActionRow(
                icon: item.isPinned ? "pin.slash" : "pin",
                title: item.isPinned ? L("action.unpin") : L("action.pin"),
                action: onTogglePin
            )

            PaperMenuDivider()
            PaperMenuActionRow(icon: "square.and.arrow.up", title: L("action.exportOne"), action: onExport)

            PaperMenuDivider()
            PaperMenuActionRow(icon: "trash", title: L("action.delete"), role: .destructive, action: onDelete)
        }
        .padding(5)
        .frame(width: 232, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(Color.appPaper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
        .onAppear {
            // Wire the flyout into the controller. Set here (not in `init`)
            // because it captures `@StateObject`s; the root menu always appears
            // before the user can hover the submenu row, so there's no race.
            submenu.present = {
                guard let frame = submenu.rowScreenFrame() else { return }
                controller.openSubmenu(besideRowAt: frame, width: 222) {
                    ClipboardGroupSubmenu(
                        membership: membership,
                        groups: groups,
                        coordinator: submenu,
                        onToggleGroup: onToggleGroup,
                        onClearGroups: onClearGroups,
                        onNewGroup: onNewGroup
                    )
                }
            }
            submenu.dismiss = { controller.closeSubmenu() }
        }
    }
}

/// Second-level paper menu listing every group with a leading selected-state,
/// plus "remove from all" and "new group". Lives in its own child panel; hover
/// over it keeps the flyout alive via the shared coordinator.
private struct ClipboardGroupSubmenu: View {
    @ObservedObject var membership: PaperGroupMembership
    let groups: [ClipboardGroup]
    @ObservedObject var coordinator: PaperSubmenuCoordinator
    let onToggleGroup: (ClipboardGroup) -> Void
    let onClearGroups: () -> Void
    let onNewGroup: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 1) {
            groupRows

            if !membership.ids.isEmpty {
                PaperMenuActionRow(
                    icon: "folder.badge.minus",
                    title: L("group.removeFromGroup"),
                    action: {
                        membership.clear()
                        onClearGroups()
                    }
                )
            }
            PaperMenuDivider()
            PaperMenuActionRow(icon: "folder.badge.plus", title: L("group.newAndMove"), action: onNewGroup)
        }
        .padding(5)
        .frame(width: 222, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(Color.appPaper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
        .onHover { coordinator.submenuHover($0) }
    }

    /// Group rows, scroll-capped so a large catalog stays a usable height.
    @ViewBuilder
    private var groupRows: some View {
        let rows = ForEach(groups) { group in
            PaperMenuCheckRow(
                title: group.displayName,
                isMember: membership.ids.contains(group.id),
                action: {
                    membership.toggle(group.id)
                    onToggleGroup(group)
                }
            )
        }
        if groups.count > 7 {
            ScrollView { VStack(spacing: 1) { rows } }
                .frame(height: 232)
        } else {
            rows
        }
    }
}

// MARK: - Rename sheet

/// Modal for assigning a custom title to a clipboard row. Empty / whitespace
/// input clears the custom title back to the default heuristic.
private struct RenameClipSheet: View {
    let initialTitle: String
    let fallback: String
    let onCommit: (String) -> Void
    let onCancel: () -> Void

    @State private var draft: String = ""
    @FocusState private var focused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            HStack(spacing: 8) {
                Image(systemName: "pencil")
                    .foregroundStyle(Color.appAccent)
                Text(L("rename.title"))
                    .font(.system(size: 14, weight: .semibold))
                Spacer()
            }

            Text(L("rename.subtitle"))
                .font(.system(size: 11))
                .foregroundStyle(.secondary)

            TextField(fallback, text: $draft)
                .focused($focused)
                .onSubmit { onCommit(draft) }
                .paperTextField(focused: focused)

            HStack {
                Spacer()
                Button(L("common.cancel"), action: onCancel)
                    .buttonStyle(PaperActionButtonStyle(role: .plain))
                    .keyboardShortcut(.cancelAction)
                Button(L("common.save")) { onCommit(draft) }
                    .buttonStyle(PaperActionButtonStyle(role: .primary))
                    .keyboardShortcut(.defaultAction)
            }
        }
        .padding(18)
        .frame(width: 360)
        .onAppear {
            draft = initialTitle
            focused = true
        }
    }
}

private struct GroupEditorRequest: Identifiable {
    enum Kind {
        case create(assigning: [ClipboardItem])
        case rename(ClipboardGroup)
    }

    let id = UUID()
    let kind: Kind

    static func create(assigning item: ClipboardItem? = nil) -> GroupEditorRequest {
        GroupEditorRequest(kind: .create(assigning: item.map { [$0] } ?? []))
    }

    static func create(assigning items: [ClipboardItem]) -> GroupEditorRequest {
        GroupEditorRequest(kind: .create(assigning: items))
    }

    static func rename(_ group: ClipboardGroup) -> GroupEditorRequest {
        GroupEditorRequest(kind: .rename(group))
    }

    var title: String {
        switch kind {
        case .create: return L("group.create.title")
        case .rename: return L("group.rename.title")
        }
    }

    var actionTitle: String {
        switch kind {
        case .create: return L("common.add")
        case .rename: return L("common.save")
        }
    }

    var initialName: String {
        switch kind {
        case .create: return ""
        case .rename(let group): return group.displayName
        }
    }
}

private struct GroupEditorSheet: View {
    let title: String
    let initialName: String
    let actionTitle: String
    let onCommit: (String) -> Void
    let onCancel: () -> Void

    @State private var draft = ""
    @FocusState private var focused: Bool

    var body: some View {
        VStack(alignment: .leading, spacing: 14) {
            HStack(spacing: 8) {
                Image(systemName: "folder.fill")
                    .foregroundStyle(Color.appAccent)
                Text(title)
                    .font(.system(size: 14, weight: .semibold))
                Spacer()
            }

            Text(L("group.editor.subtitle"))
                .font(.system(size: 11))
                .foregroundStyle(.secondary)

            TextField(L("group.name.placeholder"), text: $draft)
                .focused($focused)
                .onSubmit { commit() }
                .paperTextField(focused: focused)

            HStack {
                Spacer()
                Button(L("common.cancel"), action: onCancel)
                    .buttonStyle(PaperActionButtonStyle(role: .plain))
                    .keyboardShortcut(.cancelAction)
                Button(actionTitle) { commit() }
                    .buttonStyle(PaperActionButtonStyle(role: .primary))
                    .keyboardShortcut(.defaultAction)
            }
        }
        .padding(18)
        .frame(width: 360)
        .onAppear {
            draft = initialName
            focused = true
        }
    }

    private func commit() {
        onCommit(draft)
    }
}

@MainActor
private struct GroupManagementSheet: View {
    @EnvironmentObject var vm: ClipboardViewModel
    @Environment(\.modelContext) private var modelContext
    @Query private var groups: [ClipboardGroup]

    let onClose: () -> Void

    @State private var newGroupName = ""
    @State private var deleteTarget: ClipboardGroup?
    @State private var showDeleteAlert = false
    @FocusState private var newGroupFocused: Bool

    private var sortedGroups: [ClipboardGroup] {
        groups.sortedForDisplay()
    }

    var body: some View {
        VStack(spacing: 0) {
            header
            Divider()
            content
            Divider()
            footer
        }
        .frame(width: 520, height: 500)
        .background(Color.appPaper)
        .alert(L("confirm.deleteGroup.title"), isPresented: $showDeleteAlert) {
            Button(L("common.cancel"), role: .cancel) {
                deleteTarget = nil
            }
            Button(L("common.delete"), role: .destructive) {
                confirmDelete()
            }
        } message: {
            Text(L("confirm.deleteGroup.message"))
        }
        .onChange(of: showDeleteAlert) { _, showing in
            if !showing { deleteTarget = nil }
        }
    }

    private var header: some View {
        HStack(spacing: 9) {
            Image(systemName: "folder.badge.gearshape")
                .foregroundStyle(Color.appAccent)
            Text(L("group.manage.title"))
                .font(.system(size: 14, weight: .semibold))
            Spacer()
            Button(action: onClose) {
                Image(systemName: "xmark")
                    .font(.system(size: 12, weight: .bold))
            }
            .buttonStyle(PaperIconButtonStyle(size: 28))
            .help(L("common.close"))
            .keyboardShortcut(.cancelAction)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
    }

    private var content: some View {
        VStack(alignment: .leading, spacing: 14) {
            Text(L("group.manage.subtitle"))
                .font(.system(size: 11.5))
                .foregroundStyle(.secondary)

            HStack(spacing: 8) {
                TextField(L("group.name.placeholder"), text: $newGroupName)
                    .focused($newGroupFocused)
                    .onSubmit { addGroup() }
                    .paperTextField(focused: newGroupFocused)

                Button {
                    addGroup()
                } label: {
                    Label(L("common.add"), systemImage: "plus")
                }
                .buttonStyle(PaperActionButtonStyle(role: .primary))
            }

            if sortedGroups.isEmpty {
                VStack(spacing: 8) {
                    Image(systemName: "tray")
                        .font(.system(size: 28, weight: .light))
                    Text(L("group.manage.empty"))
                        .font(.system(size: 13, weight: .medium))
                    Text(L("group.manage.empty.subtitle"))
                        .font(.system(size: 11))
                }
                .foregroundStyle(.secondary)
                .frame(maxWidth: .infinity, maxHeight: .infinity)
                .padding(.vertical, 32)
            } else {
                ScrollView {
                    LazyVStack(spacing: 8) {
                        ForEach(sortedGroups) { group in
                            GroupManagementRow(
                                group: group,
                                canMoveUp: sortedGroups.first?.id != group.id,
                                canMoveDown: sortedGroups.last?.id != group.id,
                                onRename: { name in
                                    vm.renameGroup(group, to: name, context: modelContext)
                                    ToastCenter.shared.show(
                                        L("group.renamed"),
                                        systemImage: "pencil",
                                        tint: .appAccent
                                    )
                                },
                                onMoveUp: {
                                    withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
                                        vm.moveGroup(group, direction: -1, groups: groups, context: modelContext)
                                    }
                                },
                                onMoveDown: {
                                    withAnimation(.spring(response: 0.32, dampingFraction: 0.82)) {
                                        vm.moveGroup(group, direction: 1, groups: groups, context: modelContext)
                                    }
                                },
                                onDelete: {
                                    deleteTarget = group
                                    showDeleteAlert = true
                                }
                            )
                        }
                    }
                    .padding(.vertical, 2)
                }
            }
        }
        .padding(16)
    }

    private var footer: some View {
        HStack {
            Text(L("settings.data.groups.item.subtitle"))
                .font(.system(size: 11))
                .foregroundStyle(.secondary)
            Spacer()
            Button(L("common.done"), action: onClose)
                .buttonStyle(PaperActionButtonStyle(role: .primary))
                .keyboardShortcut(.defaultAction)
        }
        .padding(.horizontal, 16)
        .padding(.vertical, 12)
    }

    private func addGroup() {
        guard let group = vm.createGroup(named: newGroupName, groups: groups, context: modelContext) else { return }
        newGroupName = ""
        newGroupFocused = true
        ToastCenter.shared.show(
            L("group.createdFormat", group.displayName),
            systemImage: "folder.badge.plus",
            tint: .appAccent
        )
    }

    private func confirmDelete() {
        guard let deleteTarget else { return }
        let name = deleteTarget.displayName
        vm.deleteGroup(deleteTarget, context: modelContext)
        ToastCenter.shared.show(
            L("group.deletedFormat", name),
            systemImage: "folder.badge.minus",
            tint: .red
        )
        self.deleteTarget = nil
    }
}

private struct GroupManagementRow: View {
    let group: ClipboardGroup
    let canMoveUp: Bool
    let canMoveDown: Bool
    let onRename: (String) -> Void
    let onMoveUp: () -> Void
    let onMoveDown: () -> Void
    let onDelete: () -> Void

    @State private var draft = ""
    @FocusState private var focused: Bool

    var body: some View {
        HStack(spacing: 8) {
            Image(systemName: "folder.fill")
                .font(.system(size: 13, weight: .semibold))
                .foregroundStyle(Color.appAccent)
                .frame(width: 22)

            TextField(L("group.name.placeholder"), text: $draft)
                .focused($focused)
                .textFieldStyle(.plain)
                .font(.system(size: 12.5))
                .padding(.horizontal, 9)
                .padding(.vertical, 7)
                .background(
                    RoundedRectangle(cornerRadius: 7, style: .continuous)
                        .fill(Color.appChipFill)
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 7, style: .continuous)
                        .strokeBorder(
                            focused ? Color.appAccent.opacity(0.55) : Color.appCardBorder,
                            lineWidth: 0.6
                        )
                )
                .onSubmit { commitRename() }
                .onChange(of: focused) { _, isFocused in
                    if !isFocused { commitRename() }
                }

            Button(action: commitRename) {
                Image(systemName: "checkmark")
            }
            .buttonStyle(PaperIconButtonStyle(size: 28))
            .help(L("common.save"))

            Button(action: onMoveUp) {
                Image(systemName: "chevron.up")
            }
            .buttonStyle(PaperIconButtonStyle(size: 28))
            .disabled(!canMoveUp)
            .help(L("group.moveUp"))

            Button(action: onMoveDown) {
                Image(systemName: "chevron.down")
            }
            .buttonStyle(PaperIconButtonStyle(size: 28))
            .disabled(!canMoveDown)
            .help(L("group.moveDown"))

            Button(action: onDelete) {
                Image(systemName: "trash")
            }
            .buttonStyle(PaperIconButtonStyle(size: 28))
            .help(L("group.delete"))
        }
        .padding(10)
        .background(
            RoundedRectangle(cornerRadius: 12, style: .continuous)
                .fill(Color.appCard.opacity(0.82))
        )
        .overlay(
            RoundedRectangle(cornerRadius: 12, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .onAppear { draft = group.displayName }
        .onChange(of: group.name) { _, newValue in
            let trimmed = newValue.trimmingCharacters(in: .whitespacesAndNewlines)
            if !focused { draft = trimmed.isEmpty ? L("group.untitled") : trimmed }
        }
    }

    private func commitRename() {
        let trimmed = draft.trimmingCharacters(in: .whitespacesAndNewlines)
        guard trimmed != group.displayName else { return }
        onRename(trimmed)
    }
}

/// Conditional simultaneous tap gesture used by the row to set keyboard focus
/// on single click without triggering the double-tap disambiguation delay.
private struct FocusTapModifier: ViewModifier {
    let enabled: Bool
    let action: () -> Void

    func body(content: Content) -> some View {
        if enabled {
            content.simultaneousGesture(TapGesture(count: 1).onEnded(action))
        } else {
            content
        }
    }
}

// MARK: - Header stat chip

private struct HeaderStat: View {
    let value: String
    let label: String
    var tint: Color? = nil

    var body: some View {
        HStack(spacing: 4) {
            Text(value)
                .font(.system(size: 12, weight: .semibold, design: .rounded))
                .monospacedDigit()
                .foregroundStyle(tint ?? .primary)
            Text(label)
                .font(.system(size: 11))
                .foregroundStyle(.secondary)
        }
    }
}

private struct HeaderStatDivider: View {
    var body: some View {
        Circle()
            .fill(.tertiary)
            .frame(width: 2.5, height: 2.5)
            .opacity(0.6)
    }
}

// MARK: - Toolbar filters

private struct GroupFilterMenu: View {
    let groups: [ClipboardGroup]
    @Binding var selection: ClipboardGroupFilter
    let onManageGroups: () -> Void

    @State private var hovering = false
    @StateObject private var controller = PaperDropdownController()

    var body: some View {
        Button {
            togglePanel()
        } label: {
            HStack(spacing: 5) {
                Image(systemName: selection.isAll ? "folder" : "folder.fill")
                    .font(.system(size: 13, weight: .semibold))
                Text(label)
                    .font(.system(size: 12, weight: .medium))
                    .lineLimit(1)
                Image(systemName: "chevron.down")
                    .font(.system(size: 8, weight: .bold))
                    .foregroundStyle(.tertiary)
            }
            .foregroundStyle(selection.isAll ? Color.secondary : Color.appAccent)
            .padding(.horizontal, 9)
            .frame(height: 30)
            .background(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .fill(hovering || controller.isOpen || !selection.isAll ? Color.appChipFill : Color.clear)
            )
            .overlay(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .strokeBorder(
                        controller.isOpen || !selection.isAll
                            ? Color.appCardBorder
                            : Color.clear,
                        lineWidth: 0.75
                    )
            )
        }
        .buttonStyle(.plain)
        .hoverTip(L("group.filter.help"))
        .background(FilterDropdownAnchor(controller: controller))
        .onHover { hovering = $0 }
    }

    private func togglePanel() {
        controller.toggle(width: 240) {
            GroupFilterPanel(
                groups: groups,
                selection: selection,
                onSelect: { filter in
                    selection = filter
                    controller.close()
                },
                onManageGroups: {
                    controller.close()
                    onManageGroups()
                }
            )
        }
    }

    private var label: String {
        switch selection {
        case .all:
            return L("group.all")
        case .ungrouped:
            return L("group.all")
        case .group(let id):
            return groups.first { $0.id == id }?.displayName ?? L("group.missing")
        }
    }
}

private struct GroupFilterPanel: View {
    let groups: [ClipboardGroup]
    let selection: ClipboardGroupFilter
    let onSelect: (ClipboardGroupFilter) -> Void
    let onManageGroups: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            HStack(spacing: 8) {
                Image(systemName: "folder.fill")
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(Color.appAccent)
                Text(L("group.filter.title"))
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(Color.appMetal)
                Spacer()
            }

            VStack(spacing: 1) {
                FilterListOption(
                    title: L("group.all"),
                    icon: "tray.full",
                    isSelected: selection == .all
                ) {
                    onSelect(.all)
                }
                if !groups.isEmpty {
                    Divider()
                        .padding(.vertical, 3)
                    ScrollView {
                        VStack(spacing: 1) {
                            ForEach(groups) { group in
                                FilterListOption(
                                    title: group.displayName,
                                    icon: "folder.fill",
                                    isSelected: selection == .group(group.id)
                                ) {
                                    onSelect(.group(group.id))
                                }
                            }
                        }
                    }
                    .frame(maxHeight: 180)
                }
            }
            .padding(4)
            .background(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(Color.appChipFill)
            )
            .overlay(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
            )

            Button {
                onManageGroups()
            } label: {
                HStack(spacing: 7) {
                    Image(systemName: "folder.badge.gearshape")
                        .font(.system(size: 11, weight: .semibold))
                    Text(L("group.manage"))
                        .font(.system(size: 12, weight: .semibold))
                    Spacer()
                }
                .foregroundStyle(Color.appAccent)
                .padding(.horizontal, 9)
                .padding(.vertical, 7)
                .background(
                    RoundedRectangle(cornerRadius: 7, style: .continuous)
                        .fill(Color.appAccent.opacity(0.11))
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 7, style: .continuous)
                        .strokeBorder(Color.appAccent.opacity(0.28), lineWidth: 0.75)
                )
            }
            .buttonStyle(.plain)
        }
        .padding(12)
        .frame(width: 240, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(Color.appPaper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
    }
}

private struct ToolbarFilterButton: View {
    @Binding var selectedType: ClipboardItemType?
    @Binding var selectedSourceApp: String?
    let sourceApps: [String]

    @State private var hovering = false
    @StateObject private var controller = PaperDropdownController()

    private var activeCount: Int {
        (selectedType == nil ? 0 : 1) + (selectedSourceApp == nil ? 0 : 1)
    }

    var body: some View {
        Button {
            togglePanel()
        } label: {
            ZStack(alignment: .topTrailing) {
                Image(systemName: activeCount > 0 ? "line.3.horizontal.decrease.circle.fill" : "line.3.horizontal.decrease.circle")
                    .font(.system(size: 15, weight: .medium))
                    .foregroundStyle(activeCount > 0 || controller.isOpen ? Color.appAccent : .secondary)
                    .frame(width: 30, height: 30)
                    .background(
                        RoundedRectangle(cornerRadius: 7)
                            .fill(hovering || controller.isOpen ? Color.secondary.opacity(0.18) : Color.clear)
                    )

                if activeCount > 0 {
                    Text("\(activeCount)")
                        .font(.system(size: 8, weight: .bold, design: .rounded))
                        .monospacedDigit()
                        .foregroundStyle(.white)
                        .frame(width: 13, height: 13)
                        .background(Circle().fill(Color.appAccent))
                        .offset(x: 4, y: -4)
                }
            }
        }
        .buttonStyle(.plain)
        .accessibilityLabel(L("toolbar.filters"))
        .hoverTip(L("toolbar.filters.help"))
        .background(FilterDropdownAnchor(controller: controller))
        .onHover { hovering = $0 }
    }

    private func togglePanel() {
        controller.toggle(width: 292) {
            ToolbarFilterPanel(
                selectedType: $selectedType,
                selectedSourceApp: $selectedSourceApp,
                sourceApps: sourceApps
            )
        }
    }
}

private struct ToolbarFilterPanel: View {
    @Binding var selectedType: ClipboardItemType?
    @Binding var selectedSourceApp: String?
    let sourceApps: [String]

    @State private var panelSelectedType: ClipboardItemType?
    @State private var panelSelectedSourceApp: String?

    init(
        selectedType: Binding<ClipboardItemType?>,
        selectedSourceApp: Binding<String?>,
        sourceApps: [String]
    ) {
        _selectedType = selectedType
        _selectedSourceApp = selectedSourceApp
        self.sourceApps = sourceApps
        _panelSelectedType = State(initialValue: selectedType.wrappedValue)
        _panelSelectedSourceApp = State(initialValue: selectedSourceApp.wrappedValue)
    }

    private var hasActiveFilters: Bool {
        panelSelectedType != nil || panelSelectedSourceApp != nil
    }

    var body: some View {
        VStack(alignment: .leading, spacing: 12) {
            HStack(spacing: 8) {
                Image(systemName: "line.3.horizontal.decrease.circle")
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(Color.appAccent)
                Text(L("filter.title"))
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(Color.appMetal)
                Spacer()
                if hasActiveFilters {
                    Button(L("common.reset")) {
                        panelSelectedType = nil
                        panelSelectedSourceApp = nil
                        selectedType = nil
                        selectedSourceApp = nil
                    }
                    .font(.system(size: 11, weight: .medium))
                    .foregroundStyle(Color.appAccent)
                    .buttonStyle(.plain)
                }
            }

            VStack(alignment: .leading, spacing: 7) {
                FilterSectionLabel(title: L("filter.type"))
                LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: 6), count: 2), spacing: 6) {
                    FilterChipOption(
                        title: L("common.allTypes"),
                        icon: "square.grid.2x2",
                        isSelected: panelSelectedType == nil
                    ) {
                        panelSelectedType = nil
                        selectedType = nil
                    }
                    ForEach(ClipboardItemType.allCases, id: \.rawValue) { type in
                        FilterChipOption(
                            title: type.displayName,
                            icon: type.icon,
                            isSelected: panelSelectedType == type
                        ) {
                            panelSelectedType = type
                            selectedType = type
                        }
                    }
                }
            }

            VStack(alignment: .leading, spacing: 7) {
                FilterSectionLabel(title: L("filter.app"))
                VStack(spacing: 1) {
                    FilterListOption(
                        title: L("common.allApps"),
                        icon: "app",
                        isSelected: panelSelectedSourceApp == nil
                    ) {
                        panelSelectedSourceApp = nil
                        selectedSourceApp = nil
                    }

                    if sourceApps.isEmpty {
                        HStack(spacing: 7) {
                            Image(systemName: "app.dashed")
                                .font(.system(size: 11, weight: .semibold))
                            Text(L("filter.noApps"))
                                .font(.system(size: 12))
                        }
                        .foregroundStyle(.secondary)
                        .frame(maxWidth: .infinity, alignment: .leading)
                        .padding(.horizontal, 9)
                        .padding(.vertical, 7)
                    } else {
                        ScrollView {
                            VStack(spacing: 1) {
                                ForEach(sourceApps, id: \.self) { app in
                                    FilterListOption(
                                        title: app,
                                        icon: "app.fill",
                                        isSelected: panelSelectedSourceApp == app
                                    ) {
                                        panelSelectedSourceApp = app
                                        selectedSourceApp = app
                                    }
                                }
                            }
                        }
                        .frame(maxHeight: 180)
                    }
                }
                .padding(4)
                .background(
                    RoundedRectangle(cornerRadius: 8, style: .continuous)
                        .fill(Color.appChipFill)
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 8, style: .continuous)
                        .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
                )
            }
        }
        .padding(12)
        .frame(width: 292, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(Color.appPaper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
    }
}

private struct FilterSectionLabel: View {
    let title: String

    var body: some View {
        Text(title)
            .font(.system(size: 11, weight: .semibold))
            .foregroundStyle(.secondary)
    }
}

private struct FilterChipOption: View {
    let title: String
    let icon: String
    let isSelected: Bool
    let action: () -> Void

    @State private var hovering = false

    var body: some View {
        Button(action: action) {
            HStack(spacing: 6) {
                Image(systemName: icon)
                    .font(.system(size: 10.5, weight: .semibold))
                    .frame(width: 14)
                Text(title)
                    .font(.system(size: 11, weight: isSelected ? .semibold : .medium))
                    .lineLimit(1)
                Spacer(minLength: 0)
            }
            .foregroundStyle(isSelected ? Color.appAccent : Color.appMetal)
            .padding(.horizontal, 8)
            .padding(.vertical, 6)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .fill(isSelected ? Color.appAccent.opacity(0.16) : (hovering ? Color.appCardHover : Color.appChipFill))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .strokeBorder(isSelected ? Color.appAccent.opacity(0.45) : Color.appCardBorder, lineWidth: 0.75)
            )
        }
        .buttonStyle(.plain)
        .onHover { hovering = $0 }
    }
}

private struct FilterListOption: View {
    let title: String
    let icon: String
    let isSelected: Bool
    let action: () -> Void

    @State private var hovering = false

    var body: some View {
        Button(action: action) {
            HStack(spacing: 8) {
                Image(systemName: icon)
                    .font(.system(size: 11, weight: .semibold))
                    .foregroundStyle(isSelected ? Color.appAccent : Color.appMetal)
                    .frame(width: 16)
                Text(title)
                    .font(.system(size: 12, weight: isSelected ? .semibold : .regular))
                    .foregroundStyle(isSelected ? Color.appAccent : Color.appMetal)
                    .lineLimit(1)
                Spacer(minLength: 10)
                Image(systemName: "checkmark")
                    .font(.system(size: 10, weight: .bold))
                    .foregroundStyle(Color.appAccent)
                    .opacity(isSelected ? 1 : 0)
            }
            .padding(.horizontal, 9)
            .padding(.vertical, 6)
            .frame(maxWidth: .infinity, alignment: .leading)
            .background(
                RoundedRectangle(cornerRadius: 6, style: .continuous)
                    .fill(isSelected ? Color.appAccent.opacity(0.16) : (hovering ? Color.appAccent.opacity(0.14) : Color.clear))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 6, style: .continuous)
                    .strokeBorder(isSelected ? Color.appAccent.opacity(0.45) : Color.clear, lineWidth: 0.75)
            )
            .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
        }
        .buttonStyle(.plain)
        .onHover { hovering = $0 }
    }
}

private struct FilterDropdownAnchor: NSViewRepresentable {
    let controller: PaperDropdownController

    func makeNSView(context: Context) -> NSView {
        let view = NSView(frame: .zero)
        MainActor.assumeIsolated { controller.anchorView = view }
        return view
    }

    func updateNSView(_ nsView: NSView, context: Context) {
        MainActor.assumeIsolated { controller.anchorView = nsView }
    }
}

// MARK: - Search field

private struct ToolbarSearchField: View {
    @Binding var text: String
    @Binding var mode: SearchMode
    /// Lowercased keys of tags that are currently narrowing the list. Owned
    /// by the VM so other components (filters, list rendering) can observe.
    @Binding var activeTags: Set<String>
    /// Display strings (original casing) of every tag in the live history —
    /// the source for the autocomplete picker.
    var availableTags: [String]
    /// Master feature toggle from settings — when false, the semantic
    /// segment hides entirely so the search bar collapses to plain text.
    var featureEnabled: Bool
    /// True while the VM is backfilling embeddings. Disables the semantic
    /// segment with a "building index" hint instead of letting users switch
    /// into a half-populated mode.
    var indexing: Bool
    /// Bumped by the toolbar's hidden ⌘F button to pull keyboard focus into
    /// the field — @FocusState is private here, so a token is the handshake.
    var focusToken: Int = 0

    @FocusState private var focused: Bool
    /// Tag mode's text field is a *local* typing buffer — it filters the
    /// dropdown but is never written to `vm.searchText`. Keeping it off the
    /// published binding is the whole performance fix: typing a tag query no
    /// longer re-renders the list or bumps the @Query's fetch limit. Only
    /// committing a chip (which mutates `activeTags`) refilters the history.
    @State private var tagDraft = ""
    /// Natural width of the active-tag chip rail. Measured so the rail can hug
    /// its content (capped) instead of a horizontal ScrollView greedily
    /// reserving its full max width — that reserved-but-empty space was leaving
    /// a gap between the chips and the text cursor.
    @State private var chipsWidth: CGFloat = 0
    /// Measured height of the search bar, used to park the suggestion panel
    /// exactly below it (the earlier alignment-guide trick let the panel drift
    /// up over the field on some layouts; a measured offset can't).
    @State private var barHeight: CGFloat = 0
    /// Measured width of the scrollable chips+field rail. Combined with
    /// `chipsWidth` it decides how much room is left for the text field before
    /// the rail starts scrolling.
    @State private var railWidth: CGFloat = 0
    /// Index (into `sortedActiveTags`) of the chip the keyboard caret currently
    /// sits on. `nil` means the caret is back in the text field — the normal
    /// state. ←/→ walk this through the chips so the user can review tags that
    /// have scrolled off-screen, and Backspace deletes the one it lands on.
    @State private var caretIndex: Int? = nil

    /// Smallest comfortable typing area kept visible at the trailing edge once
    /// the chips fill the rail and it begins scrolling.
    private let minTypingWidth: CGFloat = 90
    /// Scroll anchor for the text field, so the rail can keep the cursor in
    /// view as chips accumulate.
    private let inputFieldID = "tagSearchInput"

    /// Width the text field should take inside the rail: it fills the leftover
    /// space while chips are few, then clamps to `minTypingWidth` so the rail
    /// overflows and scrolls (pushing older chips off to the left) instead of
    /// squeezing the cursor.
    private var typingWidth: CGFloat {
        let gap: CGFloat = sortedActiveTags.isEmpty ? 0 : 4
        return max(minTypingWidth, railWidth - chipsWidth - gap)
    }

    private let tint: Color = .appAccent

    private var placeholder: String {
        // Hide the hint as soon as the user has any active filter chip —
        // TextField hides its own placeholder on non-empty text already.
        guard activeTags.isEmpty else { return "" }
        switch mode {
        case .fullText: return L("common.searchContent")
        case .semantic: return L("common.semanticSearch")
        case .tag:      return L("common.tagSearch")
        }
    }

    /// Which string the text field edits: the local tag buffer in tag mode,
    /// the shared keyword query everywhere else.
    private var fieldText: Binding<String> {
        mode == .tag ? $tagDraft : $text
    }

    /// Tags matching whatever the user has typed into the local buffer, minus
    /// the ones already picked. Returns nothing for an empty buffer — the
    /// picker is a *search*, not a browse-all list, so focusing tag mode no
    /// longer dumps every known tag into the dropdown.
    private var suggestedTags: [String] {
        let q = tagDraft.trimmingCharacters(in: .whitespaces)
        guard !q.isEmpty else { return [] }
        return availableTags.filter {
            !activeTags.contains($0.lowercased()) && $0.localizedCaseInsensitiveContains(q)
        }
    }

    /// The tag a Return keypress would add — the top of the filtered list.
    /// Highlighted in the dropdown so the Enter target is always visible.
    private var enterTarget: String? {
        tagDraft.trimmingCharacters(in: .whitespaces).isEmpty ? nil : suggestedTags.first
    }

    /// Whether the suggestion dropdown is open. Derived purely from state
    /// (focus + a non-empty query) rather than hand-synced flags, so it can
    /// never get stuck open showing every tag, and stays closed until the user
    /// actually starts typing a tag to search for.
    private var pickerVisible: Bool {
        focused && mode == .tag
            && !tagDraft.trimmingCharacters(in: .whitespaces).isEmpty
    }

    private var sortedActiveTags: [String] {
        activeTags.sorted()
    }

    /// Anything that the clear (×) button should be able to wipe.
    private var hasInput: Bool {
        !text.isEmpty || !activeTags.isEmpty || !tagDraft.isEmpty
    }

    private func displayName(forKey key: String) -> String {
        availableTags.first(where: { $0.lowercased() == key }) ?? key
    }

    /// Add `tag` as an active filter chip and reset the buffer, leaving the
    /// dropdown open so the user can keep stacking tags.
    private func selectTag(_ tag: String) {
        activeTags.insert(tag.lowercased())
        tagDraft = ""
        caretIndex = nil
    }

    /// Return key: add the highlighted suggestion, or — if the buffer matches
    /// nothing — accept it verbatim so a remembered tag can still be searched
    /// even when no item carries it yet.
    private func handleSubmit() {
        guard mode == .tag else { return }
        if let target = enterTarget {
            selectTag(target)
            return
        }
        let token = tagDraft.trimmingCharacters(in: .whitespaces)
        guard !token.isEmpty else { return }
        activeTags.insert(token.lowercased())
        tagDraft = ""
        caretIndex = nil
    }

    /// Window-local key handler for the tag field. Active only while the field
    /// is focused in tag mode; returns `true` to swallow the keystroke. With a
    /// non-empty buffer it defers entirely so normal text editing (moving the
    /// caret through typed characters, deleting them) keeps working — chip
    /// navigation only takes over once the buffer is empty.
    private func handleTagKey(_ keyCode: UInt16) -> Bool {
        guard mode == .tag else { return false }
        let tags = sortedActiveTags
        guard tagDraft.isEmpty else {
            caretIndex = nil
            return false
        }
        switch keyCode {
        case 51:  // Delete (Backspace) → remove the chip the caret is on, or last.
            return deleteAtCaret(in: tags)
        case 123: // ← step the caret left through the chips
            guard !tags.isEmpty else { return false }
            caretIndex = caretIndex.map { max(0, $0 - 1) } ?? (tags.count - 1)
            return true
        case 124: // → step back toward the field
            guard let i = caretIndex else { return false }
            caretIndex = i >= tags.count - 1 ? nil : i + 1
            return true
        default:
            return false
        }
    }

    /// Removes the chip under the caret (or the last chip when the caret is in
    /// the field), then keeps the caret on a sensible neighbor. Returns whether
    /// a chip was actually removed.
    private func deleteAtCaret(in tags: [String]) -> Bool {
        guard !tags.isEmpty else { return false }
        let target = caretIndex ?? (tags.count - 1)
        activeTags.remove(tags[target])
        let newCount = tags.count - 1
        if newCount == 0 {
            caretIndex = nil
        } else if caretIndex != nil {
            caretIndex = min(target, newCount - 1)
        }
        return true
    }

    var body: some View {
        HStack(spacing: 6) {
            Image(systemName: "magnifyingglass")
                .font(.system(size: 12, weight: .medium))
                .foregroundStyle(focused ? tint : .secondary)
                .animation(.easeOut(duration: 0.15), value: focused)

            // Chips and the text field share one horizontal scroll rail so the
            // bar behaves like a token field: as chips pile up they push the
            // cursor right until the rail overflows, then it scrolls to keep the
            // cursor pinned at the trailing edge while older chips slide off the
            // left — never clipped, never squeezed.
            ScrollViewReader { proxy in
                ScrollView(.horizontal, showsIndicators: false) {
                    HStack(spacing: 4) {
                        if !sortedActiveTags.isEmpty {
                            HStack(spacing: 4) {
                                ForEach(Array(sortedActiveTags.enumerated()), id: \.element) { index, key in
                                    TagChipInline(
                                        label: displayName(forKey: key),
                                        isSelected: caretIndex == index
                                    ) {
                                        activeTags.remove(key)
                                    }
                                    .id(key)
                                }
                            }
                            .background(
                                GeometryReader { g in
                                    Color.clear.preference(key: ChipsWidthKey.self, value: g.size.width)
                                }
                            )
                        }

                        TextField(placeholder, text: fieldText)
                            .textFieldStyle(.plain)
                            .font(.system(size: 13))
                            .focused($focused)
                            .help(L("common.searchHint.tooltip"))
                            .frame(width: typingWidth, alignment: .leading)
                            .onSubmit(handleSubmit)
                            .onExitCommand {
                                handleEscape()
                            }
                            // SwiftUI's `onKeyPress` never reaches a focused
                            // TextField — the field editor swallows editing keys
                            // first — so Backspace and ←/→ chip navigation are
                            // caught one level up by a window-local monitor.
                            .background(
                                TagFieldKeyMonitor(
                                    isActive: { focused && mode == .tag },
                                    onKey: handleTagKey
                                )
                            )
                            .id(inputFieldID)
                    }
                    // A hair of vertical room so chip borders aren't clipped by
                    // the scroll view's tight content bounds.
                    .padding(.vertical, 1)
                    .onPreferenceChange(ChipsWidthKey.self) { chipsWidth = $0 }
                }
                .frame(maxWidth: .infinity)
                .background(
                    GeometryReader { g in
                        Color.clear.preference(key: RailWidthKey.self, value: g.size.width)
                    }
                )
                .onPreferenceChange(RailWidthKey.self) { railWidth = $0 }
                // Follow the keyboard caret: center the selected chip while
                // walking ←/→, or snap back to the field when the caret returns.
                .onChange(of: caretIndex) { _, idx in
                    withAnimation(.easeOut(duration: 0.18)) {
                        if let idx, idx < sortedActiveTags.count {
                            proxy.scrollTo(sortedActiveTags[idx], anchor: .center)
                        } else {
                            proxy.scrollTo(inputFieldID, anchor: .trailing)
                        }
                    }
                }
                // When the chip set changes, keep a stale selection in range and
                // reveal the cursor as a freshly added chip lands.
                .onChange(of: activeTags.count) { old, new in
                    if let i = caretIndex, i >= new {
                        caretIndex = new > 0 ? new - 1 : nil
                    }
                    if new > old {
                        withAnimation(.easeOut(duration: 0.2)) {
                            proxy.scrollTo(inputFieldID, anchor: .trailing)
                        }
                    }
                }
                // Typing dismisses any chip selection; losing focus clears it.
                .onChange(of: tagDraft) { _, draft in
                    if !draft.isEmpty { caretIndex = nil }
                }
                .onChange(of: focused) { _, isFocused in
                    if !isFocused { caretIndex = nil }
                }
            }

            if hasInput {
                Button {
                    text = ""
                    tagDraft = ""
                    activeTags.removeAll()
                    caretIndex = nil
                } label: {
                    Image(systemName: "xmark.circle.fill")
                        .font(.system(size: 11))
                        .foregroundStyle(.tertiary)
                }
                .buttonStyle(.plain)
                .help(L("common.clearSearch"))
                .transition(.opacity)
            }

            Divider().frame(height: 12).opacity(0.4)
            SearchModeSegment(
                icon: "text.magnifyingglass",
                title: L("common.searchMode.full"),
                isOn: mode == .fullText,
                tint: .appAccent
            ) {
                switchMode(to: .fullText)
            }
            .focusable(false)
            if featureEnabled {
                SearchModeSegment(
                    icon: indexing ? "hourglass" : "sparkle",
                    title: indexing
                        ? L("common.searchMode.indexing")
                        : L("common.searchMode.semantic"),
                    isOn: mode == .semantic && !indexing,
                    tint: .appAccent,
                    disabled: indexing,
                    showsSpinner: indexing
                ) {
                    if indexing {
                        ToastCenter.shared.show(
                            L("search.semantic.indexing.toast"),
                            systemImage: "hourglass",
                            tint: .orange
                        )
                        return
                    }
                    switchMode(to: .semantic)
                }
                .focusable(false)
            }
            SearchModeSegment(
                icon: "tag",
                title: L("common.searchMode.tag"),
                isOn: mode == .tag,
                tint: .appAccent
            ) {
                switchMode(to: .tag)
            }
            .focusable(false)
        }
        .padding(.horizontal, 10)
        .padding(.vertical, 6)
        .background(
            RoundedRectangle(cornerRadius: 8, style: .continuous)
                .fill(.regularMaterial)
                .opacity(focused ? 0.95 : 0.7)
        )
        .overlay(
            RoundedRectangle(cornerRadius: 8, style: .continuous)
                .strokeBorder(
                    focused ? tint.opacity(0.6) : Color.secondary.opacity(0.18),
                    lineWidth: focused ? 1 : 0.5
                )
        )
        .frame(minWidth: 200, maxWidth: .infinity)
        .layoutPriority(1)
        // Measure the bar's own height so the panel below can be offset by it.
        .background(
            GeometryReader { g in
                Color.clear.preference(key: BarHeightKey.self, value: g.size.height)
            }
        )
        .onPreferenceChange(BarHeightKey.self) { barHeight = $0 }
        // App-styled dropdown panel (not a system popover bubble) pinned just
        // beneath the bar: a top-left overlay pushed down by the measured bar
        // height, so it always attaches *below* and never covers the search
        // field. The toolbar's zIndex lifts it above the list below.
        .overlay(alignment: .topLeading) {
            if pickerVisible {
                TagSuggestionDropdown(
                    tags: suggestedTags,
                    hasKnownTags: !availableTags.isEmpty,
                    enterTarget: enterTarget,
                    onSelect: selectTag
                )
                // Inherit the bar's width; take the wrapped content's height.
                .frame(maxWidth: .infinity)
                .fixedSize(horizontal: false, vertical: true)
                .offset(y: barHeight + 6)
                .transition(.scale(scale: 0.97, anchor: .top).combined(with: .opacity))
            }
        }
        .animation(.easeOut(duration: 0.16), value: pickerVisible)
        // If settings flip the feature off while we're in semantic mode,
        // fall back to plain text — otherwise the search bar would behave
        // semantically with no visible affordance.
        .onChange(of: featureEnabled) { _, isOn in
            if !isOn, mode == .semantic { mode = .fullText }
        }
        // Same idea while a backfill is in-flight: drop into text mode so
        // the user actually sees results until the index is ready.
        .onChange(of: indexing) { _, isOn in
            if isOn, mode == .semantic { mode = .fullText }
        }
        .onChange(of: focusToken) { _, _ in
            focused = true
        }
    }

    /// Esc while typing: first press clears the field, second press (already
    /// empty) drops focus so the list's keyboard navigation takes over —
    /// a double-Esc always exits search.
    private func handleEscape() {
        caretIndex = nil
        if fieldText.wrappedValue.isEmpty {
            focused = false
        } else {
            fieldText.wrappedValue = ""
        }
    }

    /// Commit a mode change without dragging SwiftUI's implicit animation
    /// transactions along (border tint, popover open/close, segment fill
    /// would otherwise all cross-fade and feel sluggish). Entering tag mode
    /// focuses the field and opens the dropdown; leaving it clears the local
    /// buffer so a stale query can't linger behind another mode.
    private func switchMode(to newMode: SearchMode) {
        var tx = Transaction()
        tx.disablesAnimations = true
        withTransaction(tx) {
            mode = newMode
            caretIndex = nil
            if newMode == .tag {
                text = ""
                focused = true
            } else {
                tagDraft = ""
            }
        }
    }
}

/// Publishes the natural width of the active-tag chip rail so the search bar
/// can size the rail to its content (capped) rather than reserving the full
/// max width of its horizontal ScrollView.
private struct ChipsWidthKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

/// Publishes the search bar's measured height so the tag suggestion panel can
/// be offset to sit flush beneath it.
private struct BarHeightKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

/// Publishes the width of the scrollable chips+field rail, used to decide how
/// much room is left for the text field before the rail overflows and scrolls.
private struct RailWidthKey: PreferenceKey {
    static let defaultValue: CGFloat = 0
    static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
        value = max(value, nextValue())
    }
}

/// Catches editing keys (Backspace, ←, →) while the tag-search field is focused
/// so the chips can be navigated and deleted with the keyboard. SwiftUI's
/// `onKeyPress` doesn't reach a focused TextField (its field editor consumes
/// editing keys first), so we install a window-local key monitor that runs
/// *before* the field and only consumes the event when the handler acted on it;
/// otherwise the keystroke flows through to normal text editing untouched.
private struct TagFieldKeyMonitor: NSViewRepresentable {
    /// Gate: only act while our field owns focus in tag mode.
    var isActive: () -> Bool
    /// Handles a key by its `keyCode`; returns true to swallow the keystroke.
    var onKey: (UInt16) -> Bool

    func makeNSView(context: Context) -> NSView {
        context.coordinator.isActive = isActive
        context.coordinator.onKey = onKey
        context.coordinator.install()
        return NSView()
    }

    func updateNSView(_ nsView: NSView, context: Context) {
        context.coordinator.isActive = isActive
        context.coordinator.onKey = onKey
    }

    static func dismantleNSView(_ nsView: NSView, coordinator: Coordinator) {
        coordinator.uninstall()
    }

    func makeCoordinator() -> Coordinator { Coordinator() }

    final class Coordinator {
        var isActive: (() -> Bool)?
        var onKey: ((UInt16) -> Bool)?
        private var monitor: Any?

        func install() {
            monitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
                guard let self,
                      self.isActive?() == true,
                      self.onKey?(event.keyCode) == true
                else { return event }
                return nil                            // handled → consume
            }
        }

        func uninstall() {
            if let monitor { NSEvent.removeMonitor(monitor) }
            monitor = nil
        }
    }
}

/// Inline tag pill inside the search bar. Shows the tag label plus an `×`
/// that removes it from the active filter set.
private struct TagChipInline: View {
    let label: String
    /// True when the keyboard caret is resting on this chip — drawn with a
    /// stronger sage fill + ring so the user can see which tag ←/→ landed on.
    var isSelected: Bool = false
    let onRemove: () -> Void

    @State private var hovering = false
    @State private var closeHovering = false

    private var fillOpacity: Double {
        if isSelected { return 0.28 }
        return hovering ? 0.16 : 0.10
    }

    var body: some View {
        HStack(spacing: 4) {
            Image(systemName: "tag.fill")
                .font(.system(size: 7, weight: .bold))
                .foregroundStyle(Color.appAccent.opacity(0.65))
            Text(label)
                .font(.system(size: 11, weight: .semibold))
                .foregroundStyle(Color.appAccent)
                .lineLimit(1)
            Button(action: onRemove) {
                Image(systemName: "xmark")
                    .font(.system(size: 7.5, weight: .bold))
                    .foregroundStyle(closeHovering ? .white : Color.appAccent.opacity(0.6))
                    .frame(width: 14, height: 14)
                    .background(
                        Circle().fill(closeHovering ? Color.appAccent : Color.clear)
                    )
                    .contentShape(Circle())
            }
            .buttonStyle(.plain)
            .onHover { closeHovering = $0 }
        }
        .padding(.leading, 7)
        .padding(.trailing, 4)
        .padding(.vertical, 3)
        // Continuous rounded rect (not a capsule) so the chip speaks the same
        // squircle language as the search bar, mode segments, and toolbar —
        // and a lighter sage fill so it reads as a quiet token, not a badge.
        .background(
            RoundedRectangle(cornerRadius: 6, style: .continuous)
                .fill(Color.appAccent.opacity(fillOpacity))
        )
        .overlay(
            RoundedRectangle(cornerRadius: 6, style: .continuous)
                .strokeBorder(
                    Color.appAccent.opacity(isSelected ? 0.55 : 0.22),
                    lineWidth: isSelected ? 1 : 0.5
                )
        )
        .fixedSize()
        .onHover { hovering = $0 }
        .animation(.easeOut(duration: 0.12), value: hovering)
        .animation(.easeOut(duration: 0.12), value: closeHovering)
        .animation(.easeOut(duration: 0.12), value: isSelected)
    }
}

/// Popover shown beneath the search field while the user is typing a `#tag`
/// query. Lists available tags (filtered by the query) and forwards taps to
/// the caller, which inserts the tag and clears the typed token.
/// Suggestion panel shown beneath the search field in tag mode. Replaces the
/// system `NSPopover` (arrow + vibrant chrome) with the app's own card surface
/// — sage border, warm shadow, continuous corners — and lays the candidate
/// tags out as wrapping pills so a long list fills the width instead of a
/// space-wasting single column.
private struct TagSuggestionDropdown: View {
    let tags: [String]
    /// Whether the history holds *any* tags at all — distinguishes the "no tags
    /// exist yet" hint from a "nothing matches your query" message.
    let hasKnownTags: Bool
    /// The tag Return would commit — drawn with a persistent tint + ↵ hint so
    /// the keyboard target is obvious without arrow-key navigation.
    let enterTarget: String?
    let onSelect: (String) -> Void

    var body: some View {
        Group {
            if tags.isEmpty {
                HStack(spacing: 6) {
                    Image(systemName: hasKnownTags ? "magnifyingglass" : "tag.slash")
                        .font(.system(size: 11))
                    Text(hasKnownTags ? L("search.tagPicker.empty") : L("search.tagPicker.none"))
                        .font(.system(size: 12))
                }
                .foregroundStyle(.secondary)
                .frame(maxWidth: .infinity, alignment: .leading)
                .padding(.horizontal, 12)
                .padding(.vertical, 12)
            } else {
                WrapLayout(spacing: 6, lineSpacing: 6) {
                    ForEach(tags, id: \.self) { tag in
                        TagSuggestionChip(
                            tag: tag,
                            isEnterTarget: tag == enterTarget
                        ) { onSelect(tag) }
                    }
                }
                .padding(10)
                .frame(maxWidth: .infinity, alignment: .leading)
            }
        }
        .background(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(.regularMaterial)
        )
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
        .overlay(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .shadow(color: Color.appCardShadow, radius: 14, x: 0, y: 8)
    }
}

/// Compact candidate pill in the suggestion panel — same sage rounded-rect
/// language as the active-filter chips, so picking and picked tags read as one
/// family. Highlights on hover and when it's the Return target.
private struct TagSuggestionChip: View {
    let tag: String
    var isEnterTarget: Bool = false
    let action: () -> Void

    @State private var hovering = false

    var body: some View {
        Button(action: action) {
            HStack(spacing: 4) {
                Image(systemName: "tag.fill")
                    .font(.system(size: 8, weight: .semibold))
                    .foregroundStyle(Color.appAccent.opacity(0.65))
                Text(tag)
                    .font(.system(size: 12, weight: .medium))
                    .lineLimit(1)
                if isEnterTarget {
                    Image(systemName: "return")
                        .font(.system(size: 8, weight: .bold))
                        .foregroundStyle(Color.appAccent)
                }
            }
            .foregroundStyle(Color.appAccent)
            .padding(.horizontal, 8)
            .padding(.vertical, 4)
            .background(
                RoundedRectangle(cornerRadius: 6, style: .continuous)
                    .fill(Color.appAccent.opacity(hovering || isEnterTarget ? 0.20 : 0.10))
            )
            .overlay(
                RoundedRectangle(cornerRadius: 6, style: .continuous)
                    .strokeBorder(Color.appAccent.opacity(0.22), lineWidth: 0.5)
            )
            .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous))
        }
        .buttonStyle(.plain)
        .onHover { hovering = $0 }
        .animation(.easeOut(duration: 0.12), value: hovering)
    }
}

private struct ScopeSegmentedControl: View {
    @Binding var selected: ListScope

    var body: some View {
        HStack(spacing: 0) {
            ForEach(ListScope.allCases) { scope in
                let isOn = selected == scope
                Button {
                    selected = scope
                } label: {
                    Image(systemName: scope.icon)
                        .font(.system(size: 11, weight: .semibold))
                        .foregroundStyle(isOn ? .white : .secondary)
                        .frame(width: 30, height: 24)
                        .background(
                            RoundedRectangle(cornerRadius: 5, style: .continuous)
                                .fill(isOn ? Color.appAccent : Color.clear)
                        )
                }
                .buttonStyle(.plain)
                .hoverTip(scope.displayName)
            }
        }
        .padding(2)
        .background(
            RoundedRectangle(cornerRadius: 7, style: .continuous)
                .fill(.secondary.opacity(0.12))
        )
        .overlay(
            RoundedRectangle(cornerRadius: 7, style: .continuous)
                .strokeBorder(.separator.opacity(0.25), lineWidth: 0.5)
        )
    }
}

private struct SearchModeSegment: View {
    let icon: String
    let title: String
    let isOn: Bool
    let tint: Color
    /// When true, the segment looks dimmed and reads as non-interactive.
    /// The click handler still fires (the parent uses it to surface a toast
    /// explaining why the mode is currently unavailable).
    var disabled: Bool = false
    /// Replaces the icon with a tiny progress indicator so users can tell at
    /// a glance that the disabled state is "loading" rather than "broken".
    var showsSpinner: Bool = false
    let action: () -> Void

    @State private var hovering = false

    var body: some View {
        Button(action: action) {
            HStack(spacing: 3) {
                if showsSpinner {
                    ProgressView()
                        .progressViewStyle(.circular)
                        .controlSize(.mini)
                        .scaleEffect(0.7)
                        .frame(width: 12, height: 12)
                } else {
                    Image(systemName: icon)
                        .font(.system(size: 10, weight: .semibold))
                }
                Text(title)
                    .font(.system(size: 11, weight: .semibold))
            }
            .padding(.horizontal, 7)
            .padding(.vertical, 3)
            .foregroundStyle(foreground)
            .background(
                RoundedRectangle(cornerRadius: 5, style: .continuous)
                    .fill(
                        isOn
                            ? AnyShapeStyle(tint)
                            : AnyShapeStyle(hovering && !disabled
                                ? Color.secondary.opacity(0.18)
                                : Color.clear)
                    )
            )
            .opacity(disabled ? 0.6 : 1)
        }
        .buttonStyle(.plain)
        .onHover { hovering = $0 }
    }

    private var foreground: Color {
        if disabled { return .secondary }
        if isOn { return .white }
        return hovering ? .primary : .secondary
    }
}

// MARK: - Row bounds preference

/// Each visible row publishes its frame here, keyed by item id, so the list can
/// park a single sliding focus highlight on whichever row is focused. Only the
/// rows the `LazyVStack` actually renders contribute, so this stays to ~a dozen
/// entries regardless of history size.
private struct RowBoundsKey: PreferenceKey {
    static let defaultValue: [UUID: Anchor<CGRect>] = [:]
    static func reduce(value: inout [UUID: Anchor<CGRect>], nextValue: () -> [UUID: Anchor<CGRect>]) {
        value.merge(nextValue(), uniquingKeysWith: { $1 })
    }
}

// MARK: - Load-more sentinel

/// Invisible footer that fires its callback the first time it scrolls into
/// view. Used by the paginated list to ask the outer wrapper for another
/// page. A tiny spinner is drawn to hint at "loading more" — it's all the
/// user sees before the new rows pop in.
private struct LoadMoreSentinel: View {
    let onAppear: () -> Void

    @State private var fired = false

    var body: some View {
        HStack {
            Spacer()
            ProgressView()
                .progressViewStyle(.circular)
                .controlSize(.small)
                .opacity(0.5)
            Spacer()
        }
        .frame(height: 28)
        .onAppear {
            guard !fired else { return }
            fired = true
            onAppear()
        }
    }
}

// MARK: - Static background halo

/// Wraps the accent radial gradient in an `Equatable` view so SwiftUI bails
/// out of re-evaluating it whenever the parent re-renders (every keystroke,
/// every scope flip). The decoration is constant — there's nothing to diff.
private struct BackgroundHaloView: View, Equatable {
    static func == (_: BackgroundHaloView, _: BackgroundHaloView) -> Bool { true }

    var body: some View {
        RadialGradient(
            colors: [Color.appAccent.opacity(0.10), Color.clear],
            center: UnitPoint(x: 0.08, y: -0.05),
            startRadius: 20,
            endRadius: 520
        )
        .ignoresSafeArea()
    }
}

// MARK: - Selection bar button

private struct SelectionMoveGroupButton: View {
    let groups: [ClipboardGroup]
    let showsRemoveFromGroup: Bool
    let isDisabled: Bool
    let onMove: (ClipboardGroup?) -> Void
    let onCreateGroup: () -> Void

    @State private var isHovered = false
    @StateObject private var controller = PaperDropdownController()

    var body: some View {
        Button {
            guard !isDisabled else { return }
            controller.toggle(width: 230) {
                SelectionMoveGroupPanel(
                    groups: groups,
                    showsRemoveFromGroup: showsRemoveFromGroup,
                    onMove: { group in
                        controller.close()
                        onMove(group)
                    },
                    onCreateGroup: {
                        controller.close()
                        onCreateGroup()
                    }
                )
            }
        } label: {
            HStack(spacing: 5) {
                Image(systemName: "folder")
                    .font(.system(size: 11, weight: .semibold))
                Text(L("selection.moveToGroup"))
                    .font(.system(size: 12, weight: .medium))
                Image(systemName: "chevron.down")
                    .font(.system(size: 8, weight: .bold))
                    .foregroundStyle(.tertiary)
            }
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .foregroundStyle(isDisabled ? Color.secondary.opacity(0.45) : (isHovered || controller.isOpen ? Color.appAccent : Color.secondary))
            .background(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .fill(isHovered || controller.isOpen ? Color.appAccent.opacity(0.12) : Color.clear)
            )
            .overlay(
                RoundedRectangle(cornerRadius: 7, style: .continuous)
                    .strokeBorder(controller.isOpen ? Color.appAccent.opacity(0.30) : Color.clear, lineWidth: 0.75)
            )
        }
        .buttonStyle(.plain)
        .disabled(isDisabled)
        .background(FilterDropdownAnchor(controller: controller))
        .onHover { isHovered = $0 }
    }
}

private struct SelectionMoveGroupPanel: View {
    let groups: [ClipboardGroup]
    let showsRemoveFromGroup: Bool
    let onMove: (ClipboardGroup?) -> Void
    let onCreateGroup: () -> Void

    var body: some View {
        VStack(alignment: .leading, spacing: 10) {
            HStack(spacing: 8) {
                Image(systemName: "folder")
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(Color.appAccent)
                Text(L("selection.moveToGroup"))
                    .font(.system(size: 13, weight: .semibold))
                    .foregroundStyle(Color.appMetal)
                Spacer()
            }

            VStack(spacing: 1) {
                if showsRemoveFromGroup {
                    FilterListOption(
                        title: L("group.removeFromGroup"),
                        icon: "folder.badge.minus",
                        isSelected: false
                    ) {
                        onMove(nil)
                    }
                }

                if groups.isEmpty {
                    HStack(spacing: 7) {
                        Image(systemName: "folder.badge.questionmark")
                            .font(.system(size: 11, weight: .semibold))
                        Text(L("group.emptyForMove"))
                            .font(.system(size: 12))
                    }
                    .foregroundStyle(.secondary)
                    .frame(maxWidth: .infinity, alignment: .leading)
                    .padding(.horizontal, 9)
                    .padding(.vertical, 7)
                } else {
                    if showsRemoveFromGroup {
                        Divider()
                            .padding(.vertical, 3)
                    }
                    ScrollView {
                        VStack(spacing: 1) {
                            ForEach(groups) { group in
                                FilterListOption(
                                    title: group.displayName,
                                    icon: "folder.fill",
                                    isSelected: false
                                ) {
                                    onMove(group)
                                }
                            }
                        }
                    }
                    .frame(maxHeight: 180)
                }
            }
            .padding(4)
            .background(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .fill(Color.appChipFill)
            )
            .overlay(
                RoundedRectangle(cornerRadius: 8, style: .continuous)
                    .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
            )

            Button(action: onCreateGroup) {
                HStack(spacing: 7) {
                    Image(systemName: "folder.badge.plus")
                        .font(.system(size: 11, weight: .semibold))
                    Text(L("group.newAndMove"))
                        .font(.system(size: 12, weight: .semibold))
                    Spacer()
                }
                .foregroundStyle(Color.appAccent)
                .padding(.horizontal, 9)
                .padding(.vertical, 7)
                .background(
                    RoundedRectangle(cornerRadius: 7, style: .continuous)
                        .fill(Color.appAccent.opacity(0.11))
                )
                .overlay(
                    RoundedRectangle(cornerRadius: 7, style: .continuous)
                        .strokeBorder(Color.appAccent.opacity(0.28), lineWidth: 0.75)
                )
            }
            .buttonStyle(.plain)
        }
        .padding(12)
        .frame(width: 230, alignment: .leading)
        .background(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .fill(Color.appPaper)
        )
        .overlay(
            RoundedRectangle(cornerRadius: 10, style: .continuous)
                .strokeBorder(Color.appCardBorder, lineWidth: 0.75)
        )
        .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
    }
}

private struct SelectionBarButton: View {
    let systemName: String
    let title: String
    let action: () -> Void

    @State private var isHovered = false

    var body: some View {
        Button(action: action) {
            HStack(spacing: 5) {
                Image(systemName: systemName).font(.system(size: 11, weight: .semibold))
                Text(title).font(.system(size: 12, weight: .medium))
            }
            .padding(.horizontal, 10)
            .padding(.vertical, 6)
            .foregroundStyle(isHovered ? Color.primary : Color.secondary)
            .background(
                RoundedRectangle(cornerRadius: 7)
                    .fill(isHovered ? Color.secondary.opacity(0.18) : Color.clear)
            )
        }
        .buttonStyle(.plain)
        .onHover { isHovered = $0 }
    }
}

// MARK: - Toolbar icon button

struct ToolbarIconButton: View {
    let systemName: String
    let help: String
    let action: () -> Void

    @State private var isHovered = false

    var body: some View {
        Button(action: action) {
            Image(systemName: systemName)
                .font(.system(size: 14, weight: .medium))
                .foregroundStyle(isHovered ? .primary : .secondary)
                .frame(width: 30, height: 30)
                .background(
                    RoundedRectangle(cornerRadius: 7)
                        .fill(isHovered ? Color.secondary.opacity(0.18) : .clear)
                )
        }
        .buttonStyle(.plain)
        .accessibilityLabel(help)
        .hoverTip(help)
        .onHover { isHovered = $0 }
    }
}

// MARK: - Hover tooltip

/// Lightweight hover tooltip that pops up faster than macOS's default
/// `.help(...)` (which sits behind a ~1.5s system delay). Shows a small
/// rounded label below the control after `delay` seconds of sustained hover.
/// The bubble lives in its own floating panel (`HoverTipController`), not a
/// view overlay: an overlay is clipped by whatever container the trigger
/// happens to sit in, while a panel floats above every view in the app.
private struct HoverTipModifier: ViewModifier {
    let text: String
    let delay: TimeInterval

    @State private var hovering = false
    @State private var anchorView: NSView?

    func body(content: Content) -> some View {
        content
            .onHover { isHovering in
                hovering = isHovering
                if isHovering {
                    Task { @MainActor in
                        try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
                        guard hovering, let anchorView, anchorView.window != nil else { return }
                        HoverTipController.shared.show(text: text, anchoredTo: anchorView)
                    }
                } else {
                    HoverTipController.shared.hide(for: anchorView)
                }
            }
            .background(HoverTipAnchor { anchorView = $0 })
            .onDisappear {
                hovering = false
                HoverTipController.shared.hide(for: anchorView)
            }
    }
}

/// Invisible probe filling the trigger's bounds, so the controller can resolve
/// the on-screen rect the bubble hangs from.
private struct HoverTipAnchor: NSViewRepresentable {
    let onResolve: (NSView) -> Void

    func makeNSView(context: Context) -> NSView {
        let view = NSView(frame: .zero)
        DispatchQueue.main.async { onResolve(view) }
        return view
    }

    func updateNSView(_ nsView: NSView, context: Context) {}
}

/// Owns the single floating tooltip panel. One panel is shared app-wide —
/// only one control is hovered at a time — and it is mouse-transparent and
/// never key, so it can't steal events from the control it annotates.
@MainActor
final class HoverTipController {
    static let shared = HoverTipController()

    private var panel: NSPanel?
    private weak var anchor: NSView?

    private init() {}

    func show(text: String, anchoredTo view: NSView) {
        guard let window = view.window else { return }
        dismissPanel()
        anchor = view

        let hosting = NSHostingView(rootView: HoverTipBubble(text: text))
        hosting.layoutSubtreeIfNeeded()
        let size = hosting.fittingSize
        hosting.frame = NSRect(origin: .zero, size: size)

        let rectInWindow = view.convert(view.bounds, to: nil)
        let anchorOnScreen = window.convertToScreen(rectInWindow)

        let gap: CGFloat = 5
        var origin = NSPoint(
            x: anchorOnScreen.midX - size.width / 2,
            y: anchorOnScreen.minY - gap - size.height
        )
        if let screen = window.screen ?? NSScreen.main {
            let visible = screen.visibleFrame
            if origin.y < visible.minY { origin.y = anchorOnScreen.maxY + gap }   // flip above
            origin.x = min(max(origin.x, visible.minX + 4), visible.maxX - size.width - 4)
        }

        let panel = NSPanel(
            contentRect: NSRect(origin: origin, size: size),
            styleMask: [.borderless, .nonactivatingPanel],
            backing: .buffered,
            defer: false
        )
        panel.isOpaque = false
        panel.backgroundColor = .clear
        panel.hasShadow = false
        panel.level = .popUpMenu
        panel.ignoresMouseEvents = true
        panel.contentView = hosting

        window.addChildWindow(panel, ordered: .above)
        panel.alphaValue = 0
        NSAnimationContext.runAnimationGroup { context in
            context.duration = 0.12
            panel.animator().alphaValue = 1
        }
        self.panel = panel
    }

    /// Hide only when `view` still owns the tip — a stale exit event from one
    /// control must not kill the tip another control just opened.
    func hide(for view: NSView?) {
        if let view, anchor !== view { return }
        anchor = nil
        dismissPanel()
    }

    private func dismissPanel() {
        guard let panel else { return }
        self.panel = nil
        NSAnimationContext.runAnimationGroup({ context in
            context.duration = 0.1
            panel.animator().alphaValue = 0
        }, completionHandler: {
            panel.parent?.removeChildWindow(panel)
            panel.orderOut(nil)
        })
    }
}

private struct HoverTipBubble: View {
    let text: String

    var body: some View {
        Text(text)
            .font(.system(size: 11, weight: .medium))
            .foregroundStyle(.white)
            .padding(.horizontal, 7)
            .padding(.vertical, 4)
            .background(
                RoundedRectangle(cornerRadius: 5, style: .continuous)
                    .fill(Color.black.opacity(0.85))
            )
    }
}

extension View {
    /// Faster hover tooltip than the system `.help(...)`. Default ~0.35s
    /// delay (vs. macOS's ~1.5s) so the label appears almost immediately.
    func hoverTip(_ text: String, delay: TimeInterval = 0.35) -> some View {
        modifier(HoverTipModifier(text: text, delay: delay))
    }
}
