import AppKit
import Foundation
import UniformTypeIdentifiers

// MARK: - The clipboard type
//
// `UTType.laneworkClipboard` — what a Lanework copy puts on the pasteboard under its own type, the
// JSON `ClipboardManifest` below (04-interactions.md ▸ Clipboard: "the pasteboard carries a JSON
// manifest + plain text"). Declared in `Info.plist` beside the two drag types, for the same reason
// those are: a payload nobody has declared is a payload the system will not carry. The constant
// itself lives with its two siblings in `PasteboardTypes.swift`, exported once by the app that
// owns it.

// MARK: - The manifest

/// The JSON half of the hybrid clipboard (04-interactions.md ▸ Clipboard).
///
/// **It is self-describing twice over**, and both halves earn their keep:
///
/// - `copyID` ties the pasteboard to a staging directory — `<Application Support>/Clipboard/<copyID>/`,
///   the full folder snapshots a paste reproduces byte-for-byte from — and to a pending cut. It is
///   also the whole of "the snapshot survives relaunch exactly as long as the pasteboard still points
///   at it": a sweep keeps the one directory this id names and collects every other.
/// - Each `Entry` embeds the item's complete `index.md` text as **identification metadata**
///   (04-interactions.md ▸ Clipboard, re-ruled 2026-07-29): menu validation, the refusal's wording,
///   and the plain-text flavor read it. It is emphatically **not** a materialization source — a paste
///   whose staged snapshot is missing or unreadable refuses whole and writes nothing, because "an item
///   arrives whole — index, attachments, loose files — or not at all".
///
/// `kind` and `container` are the selection's own vocabulary (`SelectionKind`, `ItemContainer`) rather than
/// near-copies of it: a clipboard payload is a selection that was copied, and the cards-XOR-lanes and
/// board-XOR-trash invariants are exactly the ones those two types already carry. Their raw
/// spellings are pasteboard API — a manifest written before a quit is decoded after the relaunch.
///
/// `entries` are in the order the copy read them — flatten order on the live side ("lane `order`,
/// then card `order`"), the trash's own sorted order on the trashed side (`SelectionGrammar.order`)
/// — which is the order a paste inserts them in.
public struct ClipboardManifest: Codable, Sendable, Equatable {

    /// Bumped only if the shape below stops being readable by an older build. Nothing branches on it
    /// today; a manifest whose version this build does not know is simply refused (`init?(data:)`),
    /// which degrades to "there is nothing to paste" rather than to a wrong paste.
    public static let currentVersion = 1

    public var version: Int

    /// The staging directory's name, and the pending cut's identity.
    public var copyID: String

    /// The **source** board's root folder. Paste needs it for nothing structural — the destination
    /// store owns every write — but a cut's move reads its folders from there, and "pasting into the
    /// source board is supported and is the within-board lane duplicate" is a claim about this value.
    public var boardRoot: String

    public var kind: SelectionKind
    public var container: ItemContainer
    public var entries: [Entry]

    /// One copied item: where its snapshot is staged, what it is called, and its bytes.
    public struct Entry: Codable, Sendable, Equatable {

        /// The source item's own UUID. Never reused at the destination — every materialization mints
        /// fresh identities — but it is what names the staged folder and what a cut's move resolves.
        public var id: String

        /// The staged subfolder under `<staging>/<copyID>/`, which is `id` itself: a selection is a
        /// set, so its members' UUIDs are unique within one copy, and `BoardWriter.copyItem` requires
        /// a UUID-shaped source folder — a positional name would be refused as a stray.
        public var folder: String

        /// The title as written, or `nil` for an untitled item — "Untitled" is a rendering, never a
        /// value (03-board-ui.md § Card face). Feeds the plain-text representation and the refused
        /// paste's banner, which names the offending entry from exactly this.
        public var title: String?

        /// The complete `index.md` at copy time — **identification metadata, never materialized**
        /// (see the type comment). Kept because it is what lets the app answer "what was on the
        /// clipboard" without touching the staging store: the plain-text flavor and a refusal's wording
        /// both come from here, and both have to work when the snapshot is exactly what is missing.
        public var index: String

        /// How many files the item's own `attachments/` held. Zero for a lane, which has none; a
        /// lane's attachments are its cards' and are counted there.
        ///
        /// Identification metadata like the rest of the entry. It used to feed the degraded paste's
        /// loss accounting ("Pasted 'Fix login' without its 3 attachments"), which is retired with the
        /// degraded paste itself — an item now arrives whole or not at all, so there is no partial
        /// arrival left to count.
        public var attachmentCount: Int

        /// A **lane** entry's cards, index text and all — "a lane entry embeds its cards' too,
        /// attachment-less". Empty for a card entry.
        ///
        /// **Exactly the lane's cards**, which needs no filter: "a lane carries exactly its cards —
        /// the trash is board-level, so there is nothing lane-nested to strip or carry"
        /// (04-interactions.md ▸ Drag and drop, resettled 2026-07-28). Like the lane's own `index`, the
        /// cards' text is identification metadata: it describes what the copy held, and nothing
        /// materializes from it.
        public var cards: [Card]

        /// One card inside a copied lane.
        public struct Card: Codable, Sendable, Equatable {
            public var id: String
            public var title: String?
            public var index: String
            public var attachmentCount: Int

            public init(id: String, title: String?, index: String, attachmentCount: Int) {
                self.id = id
                self.title = title
                self.index = index
                self.attachmentCount = attachmentCount
            }
        }

        /// Every file this entry's subtree carried in an `attachments/` — its own plus, for a lane, its
        /// cards'. Identification metadata; nothing gates on it since the degraded paste retired.
        public var totalAttachmentCount: Int {
            attachmentCount + cards.reduce(0) { $0 + $1.attachmentCount }
        }

        public init(
            id: String,
            folder: String,
            title: String?,
            index: String,
            attachmentCount: Int,
            cards: [Card] = []
        ) {
            self.id = id
            self.folder = folder
            self.title = title
            self.index = index
            self.attachmentCount = attachmentCount
            self.cards = cards
        }
    }

    public init(
        version: Int = ClipboardManifest.currentVersion,
        copyID: String,
        boardRoot: URL,
        kind: SelectionKind,
        container: ItemContainer,
        entries: [Entry]
    ) {
        self.version = version
        self.copyID = copyID
        self.boardRoot = boardRoot.path
        self.kind = kind
        self.container = container
        self.entries = entries
    }

    public var rootURL: URL { URL(fileURLWithPath: boardRoot, isDirectory: true) }

    /// The secondary representation — one title per line, untitled items rendered as the board
    /// renders them, so a ⌘V into any text field does something sane. `DragPayload.plainText`'s rule,
    /// because it is the same question asked of the other transfer mechanism.
    public var plainText: String {
        entries.map { $0.title ?? "Untitled" }.joined(separator: "\n")
    }

    // MARK: - Paste menu title

    /// **The context-menu Paste row's title** (Pipeline card 36cce96a — "the paste option should show
    /// 'Paste Card' or 'Paste Lane' or 'Paste N Cards' depending on what's in pasteboard"): "Paste
    /// Card"/"Paste Lane" for one entry, "Paste N Cards"/"Paste N Lanes" for several, plain "Paste"
    /// for no app payload at all. One pure function of the manifest, called verbatim by both context
    /// menus (`CardFaceView.pasteTitle`, `LaneView.pasteTitle`) — a title composed twice is a title
    /// that drifts twice.
    ///
    /// **`kind` and `entries.count` are the whole of it** — exactly the two fields the type comment
    /// already promises are the selection's own vocabulary and its recorded order, so this reads no
    /// more of the manifest than that. There is no *mixed* shape to compose a plural for: `kind` is
    /// singular by construction — `SelectionGrammar.mixesKinds` refuses a selection that would produce
    /// one, so `ClipboardStore.capture` never writes a manifest naming both cards and lanes. Every
    /// manifest this app ever produces is cards-only or lanes-only, which is what makes the
    /// card-family/lane-family split exhaustive rather than a case among others.
    ///
    /// **`nil` is the caller's to pass, not this function's to look up.** Both menus already hold
    /// `payload` for their own `.disabled` reads (`CardFaceView.pasteEnabled`, `LaneView.pasteEnabled`
    /// via `canPaste(into:)`), so a caller with no app payload passes `nil` rather than this function
    /// reaching for a store it has no seam to. Answering plain "Paste" here is also the whole of
    /// **foreign pasteboard content's** title: a Finder file copy or a screenshot that the paste
    /// command still accepts (`ClipboardStore.canPasteFiles`/`canPasteImage`) never decodes to a
    /// `ClipboardManifest` in the first place, so it never reaches any branch but this one.
    ///
    /// **An empty `entries` answers plain "Paste" too**, though nothing reachable ever produces one:
    /// `init?(data:)` refuses a decoded manifest with no entries, and `ClipboardStore.capture` refuses
    /// an empty selection before a manifest is ever built. The clause exists so this function has no
    /// partial case, not because the branch is live.
    public static func pasteMenuTitle(for payload: ClipboardManifest?) -> String {
        guard let payload, !payload.entries.isEmpty else { return "Paste" }
        let count = payload.entries.count
        let noun = payload.kind == .card ? "Card" : "Lane"
        guard count > 1 else { return "Paste \(noun)" }
        return "Paste \(count) \(noun)s"
    }

    // MARK: Coding

    public func encoded() -> Data? {
        try? JSONEncoder().encode(self)
    }

    public init?(data: Data) {
        guard let decoded = try? JSONDecoder().decode(ClipboardManifest.self, from: data),
              decoded.version == Self.currentVersion,
              !decoded.entries.isEmpty
        else { return nil }
        self = decoded
    }
}

// MARK: - The pasteboard seam

/// The one thing `ClipboardStore` needs from `NSPasteboard`, behind a protocol.
///
/// It exists for testability and for nothing else: every rule the clipboard owns — the sweep, the
/// at-most-one-snapshot invariant, takeover detection, the cut's voiding — is a rule *about*
/// `changeCount` and the bytes under one type, and a suite that reached for `NSPasteboard.general`
/// would be racing every other app on the machine (and every other test in the run).
///
/// `changeCount` is the whole of takeover detection: it is a machine-wide counter that AppKit bumps
/// on every `clearContents()` by anyone, so a value that moved without this store moving it means
/// somebody else owns the pasteboard now (04-interactions.md ▸ Clipboard: "voided if another app
/// takes the pasteboard").
@MainActor
public protocol ClipboardPasteboard: AnyObject {

    var changeCount: Int { get }

    /// The bytes under the clipboard type, or `nil` when the pasteboard holds someone else's content.
    func manifestData() -> Data?

    /// Replaces the pasteboard with **one** item carrying both representations, and answers the
    /// resulting `changeCount`.
    @discardableResult
    func write(manifest: Data, text: String) -> Int

    /// **Every type identifier the pasteboard currently carries** — what the image-data branch
    /// classifies (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `PastedImage.flavor`).
    ///
    /// A list rather than a set of yes/no questions, because the *rule* is a decision over a list and
    /// belongs in one place: adding a flavor to `PastedImage.verbatimTypes` must not also mean adding
    /// a method here. It subsumes `manifestData()`'s question too, and does not replace it — the
    /// manifest is read as bytes and decoded, which a type list cannot answer.
    func availableTypes() -> [String]

    /// The bytes under one type, whatever it is — the general read behind `manifestData()`'s
    /// specific one, added for the image branch (which knows its type only at runtime, off the
    /// classification above).
    func data(forType type: String) -> Data?

    /// Every file URL the pasteboard's items carry, in item order — the file-URL branch's own read
    /// (04-interactions.md ▸ Clipboard, ruled 2026-08-09; `ClipboardStore.pasteFiles`).
    ///
    /// A method of its own rather than another `data(forType:)` call, because `availableTypes()`'s
    /// first-item carve-out is wrong here: "is a file being offered at all" only needs the first
    /// item, but "which files" does not — a Finder copy of several files is several pasteboard
    /// items, each carrying `public.file-url` on its own, and this reads across all of them.
    func fileURLs() -> [URL]
}

/// The real pasteboard.
///
/// One item with two representations, written directly rather than through SwiftUI's
/// `onCopyCommand`: the item structure has to be exactly this — a known `copyID` under a known type,
/// with the plain text beside it rather than in a second item — and a mechanism that decides the
/// shape for us could not promise that.
@MainActor
public final class SystemPasteboard: ClipboardPasteboard {

    private let pasteboard: NSPasteboard

    public init(_ pasteboard: NSPasteboard = .general) {
        self.pasteboard = pasteboard
    }

    private static let type = NSPasteboard.PasteboardType(UTType.laneworkClipboard.identifier)

    public var changeCount: Int { pasteboard.changeCount }

    public func manifestData() -> Data? {
        pasteboard.data(forType: Self.type)
    }

    /// `NSPasteboard.types` — the **first item's** types, which is what "the clipboard's payload"
    /// means for every producer this branch cares about: a screenshot, a browser's Copy Image, a
    /// Finder copy, and this app's own write are all single-item writes. A multi-item pasteboard's
    /// later items are deliberately not consulted; pasting the second image of a five-image copy is a
    /// gesture nobody has asked for and would need a target grammar of its own.
    public func availableTypes() -> [String] {
        (pasteboard.types ?? []).map(\.rawValue)
    }

    public func data(forType type: String) -> Data? {
        pasteboard.data(forType: NSPasteboard.PasteboardType(type))
    }

    /// `readObjects(forClasses:options:)` rather than a per-item `data(forType:)` walk: it is the
    /// framework's own multi-item reconstruction of `public.file-url`, `.urlReadingFileURLsOnly`
    /// keeping a web URL (`public.url`, which does not conform) from ever surfacing here.
    public func fileURLs() -> [URL] {
        (pasteboard.readObjects(
            forClasses: [NSURL.self],
            options: [.urlReadingFileURLsOnly: true]
        ) as? [URL]) ?? []
    }

    @discardableResult
    public func write(manifest: Data, text: String) -> Int {
        pasteboard.clearContents()
        let item = NSPasteboardItem()
        item.setData(manifest, forType: Self.type)
        item.setString(text, forType: .string)
        pasteboard.writeObjects([item])
        return pasteboard.changeCount
    }
}
