import AppKit
import Quartz
import SwiftUI
import UniformTypeIdentifiers

// MARK: - The section header

/// A stacked small-caps header over a sidebar section (05-card-window.md ▸ The attributes sidebar:
/// "Stacked sections under small-caps headers"), with room for one quiet trailing affordance.
///
/// Shared by all five sections rather than restated in each: the attachments section is the only one
/// with an accessory today, and the header's type, weight and rule have to stay identical across the
/// stack or the accessory would be the reason one section looks different from the rest.
struct CardSidebarSectionHeader<Accessory: View>: View {

    let title: String
    @ViewBuilder var accessory: Accessory

    var body: some View {
        VStack(alignment: .leading, spacing: 4) {
            HStack(spacing: 4) {
                Text(title)
                    .font(.caption.weight(.semibold))
                    .textCase(.uppercase)
                    .foregroundStyle(.secondary)
                Spacer(minLength: 0)
                accessory
            }
            Divider()
        }
        .frame(maxWidth: .infinity, alignment: .leading)
    }
}

extension CardSidebarSectionHeader where Accessory == EmptyView {
    init(title: String) {
        self.init(title: title) { EmptyView() }
    }
}

// MARK: - CardAttachmentsSection

/// The sidebar's **Attachments** section (05-card-window.md ▸ Attachments).
///
/// ### What it shows, and why it does not look
///
/// Every top-level file of `attachments/`, in Finder order, **including files also embedded in the
/// body** — "the section is the card's complete file inventory, no reference-tracking magic; an
/// image appearing in both places is honest, not a bug". Subfolders are tolerated and not surfaced.
///
/// The list itself is `Card.attachments` off the snapshot, republished into `CardAttachments.names`
/// by the host. This view never lists a directory: the loader did that on the last reload, the board
/// face's chip reads the very same field, and a second listing here would be a surface able to
/// disagree with the first. Every write in this section is bracketed (`performWrite`), so the reload
/// that refreshes the list is app-mediated and arrives by itself.
///
/// ### Keyboard-native, which is what is new in the rewrite
///
/// "The section is focusable; arrows move between rows, **Space QuickLooks** the selected row,
/// Return opens it, ⌫ removes it" — the pathfinder's strip was pointer-only. The substrate is
/// SwiftUI's own focus system (`focusable` + `@FocusState` + `onKeyPress`) over one focusable
/// container, rather than an `NSTableView` or a row-per-focusable list: 05 says *the section* is
/// focusable, one focus stop is what a Tab user wants out of a five-section sidebar, and the rest of
/// this window is already SwiftUI. The board's keyboard grammar is a different window's and shares
/// nothing here.
struct CardAttachmentsSection: View {

    let attachments: CardAttachments
    let thumbnails: AttachmentThumbnailCache

    /// Whether the section has the keyboard. Mirrored into `CardAttachments.isFocused` because File
    /// ▸ Reveal in Finder's card-window scope turns on it.
    @FocusState private var isFocused: Bool

    @Environment(\.displayScale) private var displayScale

    private var pointSize: CGFloat { CardWindowMetrics.bodyPointSize }

    private var names: [String] { attachments.names }

    var body: some View {
        VStack(alignment: .leading, spacing: CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize)) {
            CardSidebarSectionHeader(title: "Attachments") {
                HStack(spacing: 8) {
                    if attachments.canPasteImage?() == true {
                        pasteImageAffordance
                    }
                    addAffordance
                }
            }

            if names.isEmpty {
                emptyHint
            } else {
                rows
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        .onChange(of: isFocused, initial: true) { _, focused in
            attachments.isFocused = focused
        }
    }

    // MARK: - Header

    /// **The paste-image affordance** — the ⌘V image-data branch's replacement (04-interactions.md ▸
    /// Clipboard, re-ruled 2026-08-09): present only while the pasteboard holds a picture this card
    /// could take, rather than a permanent row that would spend most of its life disabled. Its
    /// visibility and its tap read and call the exact same seam, `CardAttachments.canPasteImage`/
    /// `.pasteImage` — the header's own `CardPasteImageAffordance.isVisible` — so the button can never
    /// promise a paste it does not deliver.
    ///
    /// **Present-or-absent, the hero rows' own posture** (below): a disabled picture-paste icon
    /// sitting in this header the other 99% of the time a card window is open would be furniture
    /// nobody reads, where "it showed up" is the whole point of the control replacing a keyboard
    /// shortcut nobody could see.
    private var pasteImageAffordance: some View {
        Button {
            attachments.pasteImage?()
        } label: {
            Image(systemName: "photo.badge.plus")
                .font(.caption.weight(.semibold))
                .foregroundStyle(.secondary)
        }
        .buttonStyle(.plain)
        .help("Paste the copied picture as an attachment")
        .accessibilityLabel("Paste Image as Attachment")
    }

    /// The header's **quiet add affordance** — "a pointer twin of File ▸ Add Attachment…, no
    /// separate behavior" (11-command-nexus.md), which is why it calls the same method the menu row
    /// does rather than opening a panel of its own.
    ///
    /// Disabled under the read-only lock, where the menu row is disabled too: 02-architecture.md's
    /// every-entry-point predicate does not care which entry point.
    private var addAffordance: some View {
        Button {
            attachments.add()
        } label: {
            Image(systemName: "plus")
                .font(.caption.weight(.semibold))
                .foregroundStyle(.secondary)
        }
        .buttonStyle(.plain)
        .disabled(!AddAttachmentCommand.isEnabled(attachments))
        .help("Add Attachment…")
        .accessibilityLabel("Add Attachment")
    }

    // MARK: - Empty

    /// "Empty, the section stays with a one-line hint (drop files, or File ▸ Add Attachment…,
    /// ⇧⌘A)" — the section never disappears, because the drop surface it advertises is the whole
    /// window and a hint the user cannot find teaches nothing.
    private var emptyHint: some View {
        Text("Drop files anywhere, or File ▸ Add Attachment… (⇧⌘A)")
            .font(.caption)
            .foregroundStyle(.secondary)
            .fixedSize(horizontal: false, vertical: true)
    }

    // MARK: - Rows

    private var rows: some View {
        VStack(alignment: .leading, spacing: 1) {
            ForEach(names, id: \.self) { name in
                row(name)
            }
        }
        .frame(maxWidth: .infinity, alignment: .leading)
        // One focus stop for the whole list — 05's "the section is focusable".
        .focusable()
        .focused($isFocused)
        .onKeyPress(.upArrow) { attachments.moveSelection(by: -1); return .handled }
        .onKeyPress(.downArrow) { attachments.moveSelection(by: 1); return .handled }
        .onKeyPress(.space) { quickLookSelected(); return .handled }
        .onKeyPress(.return) { openSelected(); return .handled }
        // ⌫, and only ⌫: 05 gives the section one destructive key and the board's own delete
        // grammar is a different window's.
        .onKeyPress(.delete) { removeSelected(); return .handled }
        .accessibilityLabel("Attachments")
    }

    @ViewBuilder
    private func row(_ name: String) -> some View {
        let url = attachments.url(for: name)
        let isSelected = attachments.selected == name

        AttachmentRow(
            name: name,
            url: url,
            isSelected: isSelected,
            isHero: attachments.hero == name,
            isSectionFocused: isFocused,
            thumbnails: thumbnails,
            pointSize: pointSize,
            displayScale: displayScale
        )
        .contentShape(Rectangle())
        // Double-click opens, a single click selects (05 ▸ Attachments; the window's click grammar,
        // where clicking selects and never edits). The two-count gesture rides `simultaneousGesture`
        // rather than stacking as a second `.onTapGesture` — a sequential pair makes the single
        // click wait out the double-click interval before selecting (`CardFaceView`'s arrangement;
        // the 2026-08-06 latency fix). Simultaneity stays instant *here* because the row carries
        // `.onDrag` below, which forces immediate click delivery — a surface without a drag source
        // must branch one recogniser on `PointerClick.count` instead (`LaneView`'s empty space).
        // The first click of a pair selects, the second opens; the open re-asserting focus and
        // selection is idempotent.
        .onTapGesture {
            isFocused = true
            attachments.selected = name
        }
        .simultaneousGesture(TapGesture(count: 2).onEnded {
            isFocused = true
            attachments.selected = name
            attachments.open(name)
        })
        // **Rows drag out their file URL** (05 ▸ Attachments; 11-command-nexus.md ▸ Pointer-only
        // affordances) — which is what makes drag-to-Finder and drag-into-another-app work with no
        // export path of this app's own. An empty provider for a row whose file has gone refuses the
        // drag rather than starting one that would resolve to nothing.
        .onDrag {
            guard let url else { return NSItemProvider() }
            return NSItemProvider(contentsOf: url) ?? NSItemProvider()
        }
        .contextMenu { menu(for: name) }
    }

    /// The attachment row's context menu — **Open, Reveal in Finder, the hero row, Remove**
    /// (11-command-nexus.md ▸ Context menus), twins of the focused section's grammar keys (Return /
    /// ⌫) and of File ▸ Reveal in Finder in its attachments-focused context. No new store method, no
    /// parallel implementation: every row here calls exactly what the keyboard calls.
    ///
    /// It acts on **its own row**, not on the selection, which is what makes a right-click on an
    /// unselected row unambiguous without a select-first dance.
    ///
    /// ### The hero row is one slot with two words
    ///
    /// **"Set as Hero"** on any image row that is not already the hero, **"Remove Hero"** on the one
    /// that is, and nothing at all on a row that can be neither — a non-image file, or any row while
    /// the board is locked (03-board-ui.md § Card face ▸ Hero image; the rules themselves are
    /// `CardAttachments.canSetHero`/`canRemoveHero`, so the menu and the tests read one answer).
    ///
    /// **Present-or-absent rather than enabled-or-disabled**, which is the opposite of Remove just
    /// below it — and the difference is what the row would *mean* greyed out. A disabled Remove says
    /// "this file cannot be removed right now", which is true and useful under the lock. A disabled
    /// "Set as Hero" on a `.zip` would say "this file could be the hero, but not now", which is not
    /// true and never will be. The lock case follows the row rather than splitting it: a menu whose
    /// hero slot appears and disappears by file type and *also* greys by lock would be two rules
    /// where the section has one.
    ///
    /// It sits between the read-only pair and Remove, on the divider grammar the Board menu uses
    /// (`KanbanApp`): reads first, then the edit-shaped rows, with the destructive one last.
    @ViewBuilder
    private func menu(for name: String) -> some View {
        Button("Open") { attachments.open(name) }
        Button("Reveal in Finder") { attachments.reveal(name) }

        if CardAttachments.canSetHero(
            name, hero: attachments.hero, names: names, isEditable: attachments.isEditable
        ) {
            Divider()
            Button("Set as Hero") { attachments.setAsHero(name) }
        } else if CardAttachments.canRemoveHero(
            name, hero: attachments.hero, isEditable: attachments.isEditable
        ) {
            Divider()
            Button("Remove Hero") { attachments.removeHero() }
        }

        Divider()
        Button("Remove") { attachments.remove(name) }
            .disabled(!attachments.isEditable)
    }

    // MARK: - The grammar keys

    private func openSelected() {
        guard let selected = attachments.selected else { return }
        attachments.open(selected)
    }

    private func removeSelected() {
        guard let selected = attachments.selected else { return }
        attachments.remove(selected)
    }

    /// **Space QuickLooks the selected row** (05 ▸ Attachments) — Finder's own key, and Finder's own
    /// panel: `QLPreviewPanel` previews every row of the section with the selected one showing, so
    /// the panel's ←/→ walk the card's attachments exactly as it walks a Finder selection.
    private func quickLookSelected() {
        guard let selected = attachments.selected,
              let index = names.firstIndex(of: selected)
        else { return }
        AttachmentQuickLook.shared.toggle(
            urls: names.compactMap { attachments.url(for: $0) },
            at: index
        )
    }
}

// MARK: - One row

/// A compact row: **small QuickLook thumbnail (Finder-icon fallback) + middle-truncated filename**
/// (05-card-window.md ▸ Attachments).
///
/// Middle truncation rather than tail, because a filename's tail is its extension and a sidebar
/// 26 characters wide would otherwise turn every screenshot into `"Screen Shot 2026-07-2…"` — the
/// one part of the name that says what the file *is* is the part that would go.
private struct AttachmentRow: View {

    let name: String
    let url: URL?
    let isSelected: Bool
    /// Whether this row's file is the card's hero — spoken, and nothing more: the row draws no badge
    /// of its own, because the banner *is* the sighted signal and it is right there on the card face.
    let isHero: Bool
    let isSectionFocused: Bool
    let thumbnails: AttachmentThumbnailCache
    let pointSize: CGFloat
    let displayScale: CGFloat

    private var side: CGFloat { CardWindowMetrics.attachmentThumbnailSide(bodyPointSize: pointSize) }
    private var padding: CGFloat { CardWindowMetrics.attachmentRowPadding(bodyPointSize: pointSize) }

    private var slot: AttachmentThumbnailKey.Slot? {
        url.map { AttachmentThumbnailKey.Slot(path: $0.path, side: side) }
    }

    var body: some View {
        HStack(spacing: padding) {
            thumbnail
                .frame(width: side, height: side)
            Text(name)
                .font(.callout)
                .lineLimit(1)
                .truncationMode(.middle)
            Spacer(minLength: 0)
        }
        .padding(.horizontal, padding)
        .padding(.vertical, padding / 2)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(selectionFill, in: RoundedRectangle(cornerRadius: 4, style: .continuous))
        .foregroundStyle(isSelected && isSectionFocused ? AnyShapeStyle(.white) : AnyShapeStyle(.primary))
        .help(name)
        .accessibilityElement(children: .combine)
        .accessibilityLabel(name)
        .accessibilityValue(isHero ? AccessibilityPhrases.heroAttachment : "")
        .accessibilityAddTraits(isSelected ? .isSelected : [])
        .task(id: url?.path) {
            guard let slot, let url else { return }
            await thumbnails.load(slot, url: url, scale: displayScale)
        }
    }

    /// The selection fill follows **focus**, the standard macOS list treatment: the accent colour
    /// while the section has the keyboard, a quiet grey when it does not, so a selected row never
    /// claims to be the thing the arrow keys are about to move.
    private var selectionFill: AnyShapeStyle {
        guard isSelected else { return AnyShapeStyle(.clear) }
        return isSectionFocused ? AnyShapeStyle(.tint) : AnyShapeStyle(.quaternary)
    }

    /// The generated thumbnail once there is one, the file's Finder icon until then — and forever,
    /// for anything QuickLook declines (05: "small QuickLook thumbnail (Finder-icon fallback)").
    @ViewBuilder
    private var thumbnail: some View {
        if let slot, let image = thumbnails.thumbnail(for: slot) {
            Image(decorative: image, scale: displayScale)
                .resizable()
                .aspectRatio(contentMode: .fit)
        } else if let url {
            Image(nsImage: thumbnails.icon(forFileAt: url))
                .resizable()
                .aspectRatio(contentMode: .fit)
        } else {
            Image(systemName: "doc")
                .foregroundStyle(.secondary)
        }
    }
}

// MARK: - QuickLook

/// The Space key's panel — `QLPreviewPanel`, the system's own, shared across every card window.
///
/// **One shared presenter, because there is one shared panel**: `QLPreviewPanel.shared()` is a
/// process-wide singleton, so a per-window data source would be a set of objects racing to be the
/// one it points at. Space in a second card window simply re-points the panel at that window's
/// files, which is also what Finder does across two windows.
///
/// The panel is driven by setting its data source directly rather than through the responder
/// chain's `acceptsPreviewPanelControl(_:)` dance: this app's key view at that moment is a SwiftUI
/// focusable, which is not an `NSResponder` we own, and the direct route is the one that does not
/// depend on where SwiftUI happens to put its hosting views.
@MainActor
final class AttachmentQuickLook: NSObject, QLPreviewPanelDataSource {

    static let shared = AttachmentQuickLook()

    private var items: [URL] = []

    /// Space **toggles**, Finder's own behaviour: pressing it again on the row already showing puts
    /// the panel away rather than re-opening it.
    func toggle(urls: [URL], at index: Int) {
        guard let panel = QLPreviewPanel.shared() else { return }
        guard !urls.isEmpty, urls.indices.contains(index) else { return }

        if panel.isVisible, items == urls, panel.currentPreviewItemIndex == index {
            panel.orderOut(nil)
            return
        }

        items = urls
        panel.dataSource = self
        panel.reloadData()
        panel.currentPreviewItemIndex = index
        panel.makeKeyAndOrderFront(nil)
    }

    nonisolated func numberOfPreviewItems(in panel: QLPreviewPanel!) -> Int {
        MainActor.assumeIsolated { items.count }
    }

    /// The bridge to `NSURL` happens **outside** the isolation hop on purpose: `any QLPreviewItem`
    /// is not `Sendable`, so it may not be the thing `assumeIsolated` returns; `URL` is, so the
    /// value that crosses is the plain one and the Objective-C cast is done here.
    nonisolated func previewPanel(_ panel: QLPreviewPanel!, previewItemAt index: Int) -> (any QLPreviewItem)! {
        let url: URL? = MainActor.assumeIsolated {
            items.indices.contains(index) ? items[index] : nil
        }
        guard let url else { return nil }
        return url as NSURL
    }
}
