import AppKit
import Observation
import SwiftUI

// MARK: - The window's attachments, as a handle

/// One card window's attachments section, reduced to what things *outside* it need: which files
/// there are, which row the keyboard is on, and the two writes the section can start
/// (05-card-window.md ▸ Attachments).
///
/// `CardBodyPresentation`'s shape and for its reason — one per window, `@State` in the host,
/// published through the focus system so **menu items** (File ▸ Add Attachment…, ⇧⌘A; File ▸ Reveal
/// in Finder's third scope) can reach the frontmost card window without anyone keeping a
/// which-window-is-key register. It is deliberately not on `BoardStore`: the store is the *board's*,
/// shared by every window on it, and two card windows open on two cards of one board have two
/// different selections.
///
/// ### What it is not
///
/// It is **not** the listing's source of truth. `names` is republished from every snapshot the store
/// applies (`Card.attachments`, which the loader fills from `attachments/`'s top-level files in
/// Finder order), so the section shows what the last reload found and nothing else — the one-way
/// flow, with no second listing able to disagree with the board face's chip. Nothing here reads a
/// directory; nothing here writes a file. Both writes go out through the seams below, which the host
/// fills with the store's own bracketed methods.
@MainActor
@Observable
public final class CardAttachments {

    /// The card's own folder — `<root>/<lane>/<card>`. `nil` until the window has joined its board,
    /// which is also exactly while there is nothing to add an attachment *to*.
    public var cardFolder: URL?

    /// The files the section shows, in Finder order — `Card.attachments`, straight from the
    /// snapshot. **Every top-level file of `attachments/`**, body-embedded ones included: "the
    /// section is the card's complete file inventory, no reference-tracking magic" (05).
    public var names: [String] = [] {
        didSet { selected = Self.settle(selected, was: oldValue, is: names) }
    }

    /// Whether the section's mutations are offered at all — `!store.isReadOnly`. Under the lock the
    /// add affordance, Remove and ⌫ disable in place, which is 02-architecture.md's every-entry-point
    /// predicate applied to this section (05 names it: "the attachment row's ⌫/Remove shares the
    /// posture").
    public var isEditable = false

    /// Which row the keyboard is on, by name — names are unique within one folder, so a name is a
    /// stabler identity than an index across a reload that inserted a file above it.
    public var selected: String?

    /// **The file this card's `hero` key names**, republished from the snapshot exactly as `names`
    /// is (`Card.hero`) — `nil` for a card with no hero, and for one whose key is malformed, which is
    /// the same "no band" the face renders (03-board-ui.md § Card face ▸ Hero image).
    ///
    /// Here rather than derived in the section for `names`' reason: the sidebar must not be a second
    /// reading of the card able to disagree with the board face's. It drives one thing only — which
    /// of the two hero rows a row's context menu offers.
    public var hero: String?

    /// Whether the section currently holds keyboard focus. Read by File ▸ Reveal in Finder, whose
    /// card-window scope is "the card's folder — the selected attachment's file instead when the
    /// attachments section is focused" (11-command-nexus.md).
    public var isFocused = false

    /// Imports files into this window's card — filled by the host with `BoardStore
    /// .importAttachments(_:toCard:)`, the **same** store method the board window's Finder drop
    /// rides. One import path, one set of banners, one Finder-style collision rename.
    public var importFiles: (([URL]) -> Void)?

    /// Moves one attachment to the system Trash — filled by the host with `BoardStore
    /// .removeAttachment(named:fromCard:)`.
    public var removeFile: ((String) -> Void)?

    /// Points the card's `hero` key at a file, or removes it — filled by the host with `BoardStore
    /// .setHero(_:onCard:on:)`, this window's undo stack attached, so the step lands on the stack the
    /// gesture was issued on (13-native-undo.md ▸ Rules ▸ two levels).
    public var setHeroFile: ((String?) -> Void)?

    /// **The header's paste-image affordance's write** — filled by the host with `ClipboardStore
    /// .pasteImage(intoCard:in:)`, the same call the card window's own ⌘V used before the control
    /// replaced it, and the board's "Paste Image into Card" context-menu row still uses
    /// (04-interactions.md ▸ Clipboard, re-ruled 2026-08-09). One import path behind three pointers
    /// at it now, none of them a second implementation.
    public var pasteImage: (() -> Void)?

    /// **Whether the affordance shows at all** — filled by the host with `CardPasteImageAffordance
    /// .isVisible(clipboard:cardID:in:)`, read fresh on every view evaluation rather than cached, so
    /// the control's presence and `pasteImage()`'s success can never disagree.
    public var canPasteImage: (() -> Bool)?

    public init() {}

    // MARK: - Derived

    /// Where `name` lives on disk, or `nil` when this window has no folder yet.
    public func url(for name: String) -> URL? {
        guard let cardFolder, names.contains(name) else { return nil }
        return cardFolder
            .appendingPathComponent(BoardWriter.attachmentsFolderName, isDirectory: true)
            .appendingPathComponent(name)
    }

    /// The selected row's file, when there is one.
    public var selectedURL: URL? {
        selected.flatMap { url(for: $0) }
    }

    // MARK: - The two writes

    /// File ▸ Add Attachment… (⇧⌘A) and the header's quiet add affordance — **one act with two
    /// pointers at it** (11-command-nexus.md: the affordance "is a pointer twin of File ▸ Add
    /// Attachment…, no separate behavior").
    ///
    /// A cancelled panel imports nothing and says nothing; a panel that returns files hands them to
    /// the very same store method a whole-window drop uses.
    public func add() {
        guard isEditable, cardFolder != nil else { return }
        let urls = AttachmentPanel.chooseFiles(message: "Choose files to attach to this card.")
        guard !urls.isEmpty else { return }

        // The sandbox's half, `BoardDropContext.commitFileDrop`'s rule: `start…` answers false for a
        // URL that carries no scope of its own, so only the ones that opened are closed again.
        let scoped = urls.filter { $0.startAccessingSecurityScopedResource() }
        defer { for url in scoped { url.stopAccessingSecurityScopedResource() } }
        importFiles?(urls)
    }

    /// Remove / ⌫ — the system Trash, never a hard delete (05 ▸ Attachments).
    public func remove(_ name: String) {
        guard isEditable, names.contains(name) else { return }
        removeFile?(name)
    }

    /// **Set as Hero** — the row's file becomes the card's banner picture (05 ▸ Attachments, ruled
    /// 2026-08-09; 03-board-ui.md § Card face ▸ Hero image).
    public func setAsHero(_ name: String) {
        guard Self.canSetHero(name, hero: hero, names: names, isEditable: isEditable) else { return }
        setHeroFile?(name)
    }

    /// **Remove Hero** — the key goes, the file stays. Removing the *hero* is not removing the
    /// attachment: the picture is still one of the card's files and is still in the list, which is
    /// what keeps this row distinct from the Remove one sitting below it.
    public func removeHero() {
        guard isEditable, hero != nil else { return }
        setHeroFile?(nil)
    }

    // MARK: - The hero rows' rules

    /// Whether a row offers **Set as Hero** — the row is an image, the section can write, the file is
    /// actually in the listing, and the card's hero is not already this very file.
    ///
    /// **Image-type only** (`PastedImage.isImageName`), because the key means a picture: offering the
    /// row on a `.zip` would let a user set a hero that can never draw, and 03's structural degrade
    /// would leave them with a key and no band and nothing to explain it.
    ///
    /// **Absent, not disabled, on the current hero's row**: that row shows Remove Hero instead, which
    /// is the same slot saying the true thing. Everywhere else "Set as Hero" *replaces* whatever hero
    /// the card had — one hero per card, and a Remove-then-Set dance would be ceremony (see
    /// `BoardStore.setHero(_:onCard:on:)`).
    ///
    /// A pure static for `moved`/`settle`'s reason: the menu's two branches become lines of test
    /// rather than a context menu somebody has to open.
    public nonisolated static func canSetHero(
        _ name: String,
        hero: String?,
        names: [String],
        isEditable: Bool
    ) -> Bool {
        guard isEditable, names.contains(name), hero != name else { return false }
        return PastedImage.isImageName(name)
    }

    /// Whether a row offers **Remove Hero** — it is the card's current hero, and the section can
    /// write. The image test is deliberately *not* repeated: a hero somebody hand-wrote to a
    /// non-image file is exactly the state this row exists to get out of.
    public nonisolated static func canRemoveHero(
        _ name: String,
        hero: String?,
        isEditable: Bool
    ) -> Bool {
        isEditable && hero == name
    }

    // MARK: - Row actions that are not writes

    /// Double-click, Return, and the context menu's Open: the file's default app (05 ▸ Attachments).
    /// Enabled under the read-only lock like every other read — opening a file mutates nothing here.
    public func open(_ name: String) {
        guard let url = url(for: name) else { return }
        NSWorkspace.shared.open(url)
    }

    public func reveal(_ name: String) {
        guard let url = url(for: name) else { return }
        NSWorkspace.shared.activateFileViewerSelecting([url])
    }

    // MARK: - Keyboard

    /// ↑/↓ over the rows. Moving with nothing selected selects the first row (going down) or the
    /// last (going up), which is what makes the section usable the instant it takes focus.
    public func moveSelection(by delta: Int) {
        selected = Self.moved(selected, by: delta, in: names)
    }

    // MARK: - The pure rules

    /// Where ↑/↓ lands: **clamped, never wrapping** — a list is not a carousel, and an arrow at the
    /// end of a short list should not silently jump to the other end of it.
    ///
    /// A selection that is not in `names` (a file removed under the cursor) is treated as no
    /// selection, so the next arrow re-enters the list from its edge.
    public nonisolated static func moved(_ selected: String?, by delta: Int, in names: [String]) -> String? {
        guard !names.isEmpty else { return nil }
        guard let selected, let index = names.firstIndex(of: selected) else {
            return delta < 0 ? names.last : names.first
        }
        return names[min(max(0, index + delta), names.count - 1)]
    }

    /// Where the selection goes when the listing changes underneath it — **the row that took its
    /// place**, which is the behaviour every list in macOS has after a delete: remove the third of
    /// five files and the selection lands on the new third, not on nothing and not on the top.
    ///
    /// A selection that survived the change keeps its row (the common case: another window's import
    /// added a file elsewhere). An empty listing selects nothing. A selection that was never set
    /// stays unset — a reload must not select a row the user did not.
    public nonisolated static func settle(_ selected: String?, was previous: [String], is names: [String]) -> String? {
        guard let selected else { return nil }
        if names.contains(selected) { return selected }
        guard !names.isEmpty, let index = previous.firstIndex(of: selected) else { return nil }
        return names[min(index, names.count - 1)]
    }

    /// What File ▸ Reveal in Finder reveals in a card window: **the selected attachment's file when
    /// the attachments section is focused, the card's folder otherwise** (11-command-nexus.md).
    ///
    /// `[]` — which is the row's `disabled` condition — only when there is no card folder at all: a
    /// window on its way out. A focused section with nothing selected still reveals the card, which
    /// is the honest fallback rather than a row that goes dead when the user tabs into a list.
    public nonisolated static func revealURLs(
        cardFolder: URL?,
        selectedURL: URL?,
        isSectionFocused: Bool
    ) -> [URL] {
        if isSectionFocused, let selectedURL { return [selectedURL] }
        guard let cardFolder else { return [] }
        return [cardFolder]
    }

}

// MARK: - The panel

/// The multi-select open panel behind every "add a file" affordance in the card window — File ▸ Add
/// Attachment… and the attachments header's plus (card-scoped), and the composer's and inline
/// editor's paperclips (comment-scoped, 05-card-window.md ▸ The comments column).
///
/// **Every file type**, because a card's `attachments/` takes anything (01-storage-format.md §
/// Attachments) and a filter here would be this app deciding what the user may keep beside their
/// card. Directories are not choosable, which is the panel's own spelling of the same refusal a
/// folder drop gets (`FinderDrop`): the attachment model is flat top-level files.
///
/// Shared rather than copied per surface for `BoardWriter.importFiles`' reason one layer up: the
/// paperclip is "the section header's add-affordance pattern" and a second panel that happened to
/// allow folders would make that sentence false.
@MainActor
enum AttachmentPanel {

    /// - Parameter message: the panel's one line of guidance — the only thing that differs between
    ///   the card-scoped and comment-scoped calls, because it is the only thing that *is* different.
    static func chooseFiles(message: String) -> [URL] {
        let panel = NSOpenPanel()
        panel.canChooseFiles = true
        panel.canChooseDirectories = false
        panel.allowsMultipleSelection = true
        panel.resolvesAliases = true
        panel.prompt = "Add"
        panel.message = message
        guard panel.runModal() == .OK else { return [] }
        return panel.urls
    }
}

// MARK: - File ▸ Add Attachment…

/// File ▸ Add Attachment… (⇧⌘A) — card window only (11-command-nexus.md; 05-card-window.md ▸
/// Attachments).
///
/// The diff `FutureCommands` predicted, exactly: the title and the chord did not move, the
/// validation and the action filled in.
///
/// Validation is **scope plus the lock**. With no card window in front there is no `cardAttachments`
/// focused value and the row disables; with the board read-only it disables too, because this is a
/// mutation and 02-architecture.md's every-entry-point predicate covers menu rows as much as
/// affordances. (Contrast Edit Body, which is not a mutation and stays live under the lock.)
struct AddAttachmentCommand: View {

    @FocusedValue(\.cardAttachments) private var attachments

    /// The row's validation, as a value a test can hold — `EditBodyCommand.isEnabled`'s shape, for
    /// its reason: a menu item's `.disabled` is otherwise only observable by driving the menu bar.
    static func isEnabled(_ attachments: CardAttachments?) -> Bool {
        guard let attachments else { return false }
        return attachments.isEditable && attachments.cardFolder != nil
    }

    var body: some View {
        Button("Add Attachment…") {
            attachments?.add()
        }
        .keyboardShortcut("a", modifiers: [.shift, .command])
        .disabled(!Self.isEnabled(attachments))
    }
}

// MARK: - File ▸ Set as Hero / Remove Hero

/// **The attachment row's hero pair, as menu rows** — card window only, acting on the attachments
/// section's *selected* row (11-command-nexus.md; 05-card-window.md ▸ Attachments; 03-board-ui.md
/// § Card face ▸ Hero image).
///
/// ### Why the menu rows exist at all
///
/// 11's context-menu contract: "Every entry is a twin of a menu command, a fixed grammar key, or a
/// configuration control — **no function's only home**". The row's pointer path is the context menu;
/// these are its required twins, and they are also what makes the gesture keyboard-reachable in a
/// section 05 went out of its way to make keyboard-native.
///
/// ### Two rows, not one row with two titles
///
/// **Titles are API** (04-interactions.md ▸ Configurable bindings) — a user's custom binding is
/// stored against the title — so a single row that renamed itself would silently drop that binding
/// every time the selection moved. Two rows is `Collapse Lane`/`Expand Lane`'s answer to the same
/// shape, and for the same reason. In the *context* menu the two share one slot, because a context
/// menu is built fresh per row and carries no bindings.
///
/// The subject is the **selected** row rather than a row under a pointer, which is what a menu-bar
/// item can address at all — File ▸ Reveal in Finder's card-window scope reads the same selection.
struct SetAsHeroCommand: View {

    @FocusedValue(\.cardAttachments) private var attachments

    /// The row's validation as a value a test can hold — `AddAttachmentCommand.isEnabled`'s shape,
    /// for its reason. It is `CardAttachments.canSetHero` applied to the selected row, so the menu
    /// row and the context row can never disagree about what "an image that is not already the hero"
    /// means.
    static func isEnabled(_ attachments: CardAttachments?) -> Bool {
        guard let attachments, let selected = attachments.selected else { return false }
        return CardAttachments.canSetHero(
            selected,
            hero: attachments.hero,
            names: attachments.names,
            isEditable: attachments.isEditable
        )
    }

    var body: some View {
        Button("Set as Hero") {
            guard let attachments, let selected = attachments.selected else { return }
            attachments.setAsHero(selected)
        }
        .disabled(!Self.isEnabled(attachments))
    }
}

/// Set as Hero's other direction — see it for why the pair is two rows.
struct RemoveHeroCommand: View {

    @FocusedValue(\.cardAttachments) private var attachments

    static func isEnabled(_ attachments: CardAttachments?) -> Bool {
        guard let attachments, let selected = attachments.selected else { return false }
        return CardAttachments.canRemoveHero(
            selected,
            hero: attachments.hero,
            isEditable: attachments.isEditable
        )
    }

    var body: some View {
        Button("Remove Hero") {
            attachments?.removeHero()
        }
        .disabled(!Self.isEnabled(attachments))
    }
}

// MARK: - The focused value

/// The focused card window's attachments section, beside `FocusedValues.cardBody` — see
/// `FocusedBoardStoreKey` for why window-scoped menu items reach their window this way.
struct FocusedCardAttachmentsKey: FocusedValueKey {
    typealias Value = CardAttachments
}

extension FocusedValues {
    var cardAttachments: CardAttachments? {
        get { self[FocusedCardAttachmentsKey.self] }
        set { self[FocusedCardAttachmentsKey.self] = newValue }
    }
}
