import AppKit
import Foundation
import Testing
import UniformTypeIdentifiers
@testable import Kanban

/// The card window's **Attachments** section (05-card-window.md ▸ Attachments) — everything about it
/// that is not a pixel.
///
/// Four things are covered here and the split is deliberate:
///
/// - **The remove write**, end to end: a file leaves `attachments/` and arrives in the *system*
///   Trash, the store's version rides the write bracket, and a failure banners with the Finder
///   phrasing the naming constraint reserves for exactly this operation.
/// - **Add Attachment's target resolution** — the seam `CardWindowHost` fills in, driven as the real
///   wiring rather than a re-typed copy of it, exactly as `RawSourceTests` drives
///   `configureRawSource`.
/// - **The payload split**, as the pure predicate it is: file drags are the window's, text drags are
///   the editor's, folders refuse.
/// - **The section's pure rules** — arrow movement, what a selection does when the listing changes
///   under it, and what Reveal in Finder points at.
///
/// What is deliberately *not* here: the import path itself (already covered board-side —
/// `FileDropWriteTests`, including the Finder-style collision rename this section reuses verbatim),
/// the listing (already covered loader-side — `BoardLoaderTests` ▸ Card attachments, and the section
/// reads that very field rather than a seam of its own), and the view layer's keyboard and QuickLook,
/// which need a window.

// MARK: - Fixtures

@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
    try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
    return fixture
}

private let lane1 = ItemID(rawValue: Ident.lane1)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)

private func cardFolder(_ fixture: WriterFixture, _ card: String = Ident.card1) -> URL {
    fixture.url("\(Ident.lane1)/\(card)")
}

/// A name nothing else in the user's Trash can already be called.
///
/// Every test below trashes into the **real** `~/.Trash`: `trashItem` has nowhere else to put a file
/// that lives in a temp directory on the boot volume, and faking it would be faking the one thing
/// these tests exist to prove. Unique names mean the tidy-up can only ever remove this run's own
/// residue, and a Trash that already held a `shot.png` is never touched.
private func uniqueName(_ stem: String, _ ext: String) -> String {
    "\(stem)-\(UUID().uuidString).\(ext)"
}

/// Takes a trashed file back out of `~/.Trash`, and answers whether it was there — **the assertion
/// and the tidy-up in one call**, because they want the same lookup: a file that can be purged is a
/// file that reached the Trash rather than being hard-deleted.
@discardableResult
private func purgeFromTrash(_ url: URL?) -> Bool {
    guard let url, FileManager.default.fileExists(atPath: url.path) else { return false }
    try? FileManager.default.removeItem(at: url)
    return true
}

/// A `ClipboardStore` for the tests below that only need to satisfy `configureAttachments`'s
/// paste-image seam — the affordance itself is `PasteImageTests`' subject, not this file's. A
/// `FakePasteboard` (`ClipboardTests.swift`'s double, never the machine's) and a throwaway staging
/// directory the caller tears down alongside its board fixture.
@MainActor
private func makeScratchClipboard() throws -> (clipboard: ClipboardStore, staging: URL) {
    let staging = FileManager.default.temporaryDirectory
        .appendingPathComponent("CardAttachmentsTests-\(UUID().uuidString)", isDirectory: true)
    try FileManager.default.createDirectory(at: staging, withIntermediateDirectories: true)
    let clipboard = ClipboardStore(pasteboard: FakePasteboard(), stagingRoot: staging, observesActivation: false)
    return (clipboard, staging)
}

/// The same, by name, for the paths that do not hand the resulting URL back (the store's).
@discardableResult
private func purgeFromTrash(named name: String) -> Bool {
    guard let trash = try? FileManager.default.url(
        for: .trashDirectory, in: .userDomainMask, appropriateFor: nil, create: false
    ) else { return false }
    return purgeFromTrash(trash.appendingPathComponent(name))
}

// MARK: - The remove write

@MainActor
@Suite("BoardWriter ▸ removeAttachment(named:fromCard:)")
struct RemoveAttachmentWriterTests {

    /// The promise in one test: the file leaves `attachments/`, its siblings do not, and it is
    /// **still on disk** — in the Trash, where the user can put it back without this app's help
    /// (05-card-window.md ▸ Attachments: "moves to the **system** Trash, never hard-deletes").
    @Test("The file leaves attachments/ and arrives in the system Trash")
    func movesToTheSystemTrash() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let doomed = uniqueName("shot", "png")
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/notes.txt", Data([0x02]))

        let trashed = try BoardWriter.removeAttachment(named: doomed, fromCard: cardFolder(fixture))

        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["notes.txt"])
        #expect(purgeFromTrash(trashed), "moved to the Trash, never hard-deleted")
    }

    /// **The listing is the guard**, and this is the whole class of things it refuses in one test:
    /// a subfolder, a file inside one, a hidden file, a path, and a name that is simply not there.
    /// None of them is a failure — the reload is the authority on what a card has — so nothing is
    /// thrown, nothing is written, and the card's own `index.md` is never opened.
    @Test("Anything the listing does not show is a silent no-op")
    func onlyTopLevelFilesAreRemovable() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let card = cardFolder(fixture)
        let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
        try fixture.file("\(attachments)/keep.png", Data([0x01]))
        try fixture.file("\(attachments)/sub/nested.txt", Data([0x02]))
        try fixture.file("\(attachments)/.DS_Store", Data([0x03]))
        let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")

        for name in ["sub", "sub/nested.txt", ".DS_Store", "gone.png", "", "../index.md"] {
            #expect(try BoardWriter.removeAttachment(named: name, fromCard: card) == nil)
        }

        #expect(try fixture.entryNames(attachments).sorted() == [".DS_Store", "keep.png", "sub"])
        #expect(try fixture.data("\(attachments)/sub/nested.txt") == Data([0x02]))
        #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
    }

    /// Attachments belong to cards: a lane folder, a board root and a folder that is not there at
    /// all are refused before anything is looked at — `importAttachments`' own guards, since this is
    /// its inverse.
    @Test("A folder that is not a card is refused")
    func refusesNonCards() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let atRoot = writeFailure {
            _ = try BoardWriter.removeAttachment(named: "shot.png", fromCard: fixture.root)
        }
        #expect(atRoot?.operation == .removeAttachment(filename: "shot.png"))

        let missing = writeFailure {
            _ = try BoardWriter.removeAttachment(
                named: "shot.png",
                fromCard: fixture.url("\(Ident.lane1)/\(Ident.indexless)-nope")
            )
        }
        #expect(missing != nil)
    }
}

// MARK: - The store's half

@MainActor
@Suite("BoardStore ▸ removeAttachment(named:fromCard:)")
struct RemoveAttachmentStoreTests {

    /// **A bracketed write like every other**: it goes out through `performWrite`, so the churn
    /// rounds back as one app-mediated reload, and a success says nothing at all.
    @Test("Removing rides the write bracket and posts nothing on success")
    func removesThroughTheBracket() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let doomed = uniqueName("shot", "png")
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
        let store = try BoardStore(rootURL: fixture.root)

        store.removeAttachment(named: doomed, fromCard: card1)

        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
        #expect(purgeFromTrash(named: doomed), "the store's remove is the Trash's, not a delete")
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.banners.losses.isEmpty)
    }

    /// The vanished-target guard every gesture in the store makes, ancestor-walked: a tombstoned
    /// card, a live card under a tombstoned lane, a card that isn't there, a *lane* id, and an empty
    /// name all write nothing and say nothing.
    @Test("A tombstoned, ancestor-tombstoned, or vanished target writes nothing")
    func inertTargets() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", """
            ---
            schema: 1
            title: Second
            order: 2048
            created: 2026-01-01T09:00:00Z
            deleted: 2026-03-03T09:00:00Z
            ---
            Second body.

            """)
        try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/shot.png", Data([0x01]))
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x02]))
        let store = try BoardStore(rootURL: fixture.root)

        store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.lane2))      // a lane
        store.removeAttachment(named: "shot.png", fromCard: ItemID(rawValue: Ident.indexless))  // no such card
        store.removeAttachment(named: "shot.png", fromCard: lane1)                              // a lane
        store.removeAttachment(named: "", fromCard: card1)                                      // no name

        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
        #expect(store.banners.oneShots.isEmpty, "a vanished target is a silent no-op, not a failure")
    }

    /// A genuine failure banners, in **Finder's** words — the one operation in this app allowed
    /// "move to the Trash", because it is the one that uses the system Trash (03-board-ui.md
    /// § Trash's naming constraint). The file stays exactly where it was.
    @Test("A remove that cannot happen banners with the system-Trash phrasing")
    func aFailingRemoveBanners() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let attachments = "\(Ident.lane1)/\(Ident.card1)/attachments"
        try fixture.file("\(attachments)/shot.png", Data([0x01]))
        let store = try BoardStore(rootURL: fixture.root)
        // Unlinking is the *parent* directory's permission, so this is what a folder the user
        // cannot write to looks like from here.
        try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: fixture.url(attachments).path)

        store.removeAttachment(named: "shot.png", fromCard: card1)

        #expect(try fixture.entryNames(attachments) == ["shot.png"], "a failed remove leaves the file")
        #expect(store.banners.oneShots.count == 1)
        #expect(store.banners.oneShots.first?.error.operation == .removeAttachment(filename: "shot.png"))
        #expect(
            BannerCenter.headline(for: BoardWriteError(
                operation: .removeAttachment(filename: "shot.png"),
                path: "/x",
                reason: .io(message: "permission denied")
            )).hasPrefix("Couldn't move 'shot.png' to the Trash")
        )
    }
}

// MARK: - Add Attachment's target resolution

@MainActor
@Suite("CardWindowHost ▸ configureAttachments")
struct AddAttachmentTargetTests {

    /// **The window's card, and no other** — the whole of the wiring's job. Driven through the real
    /// `configureAttachments`, so the test breaks if the two seams are ever crossed or the id is
    /// captured from the wrong place.
    @Test("Adding imports into the window's own card, through the board's import path")
    func importsIntoItsOwnCard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let sources = try WriterFixture()
        defer { sources.tearDown() }
        let shot = try sources.file("shot.png", Data([0x89, 0x50]))
        let store = try BoardStore(rootURL: fixture.root)
        let (clipboard, staging) = try makeScratchClipboard()
        defer { try? FileManager.default.removeItem(at: staging) }

        let attachments = CardAttachments()
        CardWindowHost.configureAttachments(
            attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard
        )
        attachments.importFiles?([shot])

        #expect(try fixture.data("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png") == Data([0x89, 0x50]))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card2)/attachments"))
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("Removing takes the file out of the window's own card")
    func removesFromItsOwnCard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let doomed = uniqueName("shot", "png")
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/\(doomed)", Data([0x01]))
        try fixture.file("\(Ident.lane1)/\(Ident.card2)/attachments/\(doomed)", Data([0x02]))
        let store = try BoardStore(rootURL: fixture.root)
        let (clipboard, staging) = try makeScratchClipboard()
        defer { try? FileManager.default.removeItem(at: staging) }

        let attachments = CardAttachments()
        CardWindowHost.configureAttachments(
            attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard
        )
        attachments.removeFile?(doomed)

        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments").isEmpty)
        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card2)/attachments") == [doomed])
        purgeFromTrash(named: doomed)
    }

    /// The handle's own gates, in front of the seams: under the read-only lock and before the window
    /// has a folder, `remove` does nothing at all — 02-architecture.md's every-entry-point predicate,
    /// which is also `AddAttachmentCommand`'s validation.
    @Test("The lock disables the section's writes, and the menu row with them")
    func theLockDisablesTheSectionsWrites() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01]))
        let store = try BoardStore(rootURL: fixture.root)
        let (clipboard, staging) = try makeScratchClipboard()
        defer { try? FileManager.default.removeItem(at: staging) }

        let attachments = CardAttachments()
        CardWindowHost.configureAttachments(
            attachments, store: store, cardID: card1, undo: CardWindowUndo(), clipboard: clipboard
        )
        attachments.cardFolder = cardFolder(fixture)
        attachments.names = ["shot.png"]

        attachments.isEditable = false
        attachments.remove("shot.png")
        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)/attachments") == ["shot.png"])
        #expect(!AddAttachmentCommand.isEnabled(attachments))

        attachments.isEditable = true
        #expect(AddAttachmentCommand.isEnabled(attachments))
        #expect(!AddAttachmentCommand.isEnabled(nil), "no card window in front, no row")

        // A window with no folder yet has nothing to add *to*.
        attachments.cardFolder = nil
        #expect(!AddAttachmentCommand.isEnabled(attachments))
    }
}

// MARK: - The payload split

@Suite("CardWindowDrop ▸ the text/file payload split")
struct CardWindowDropPayloadTests {

    /// A Finder drag: the file URL beside the concrete type. The window's.
    @Test("A file drag is the window's, whatever it is a file of")
    func fileDragsAreTheWindows() {
        #expect(CardWindowDrop.isFilePayload(typeIdentifiers: ["public.file-url", "public.png"]))
        #expect(CardWindowDrop.accepts(payloads: [["public.file-url", "public.png"]]))
        #expect(CardWindowDrop.accepts(payloads: [["public.file-url", "public.plain-text"]]))
    }

    /// **Dragged text is never the window's** — it belongs to the Edit editor at the caret, and is
    /// inert elsewhere (05-card-window.md ▸ Attachments). A link dragged out of a browser is
    /// `public.url`, which does *not* conform to `public.file-url`, so it stays text.
    @Test("Text and browser links are not file payloads")
    func textIsNeverTheWindows() {
        #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.utf8-plain-text"]))
        #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.url", "public.utf8-plain-text"]))
        #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: ["public.rtf"]))
        #expect(!CardWindowDrop.isFilePayload(typeIdentifiers: []))
        #expect(!CardWindowDrop.accepts(payloads: [["public.utf8-plain-text"], ["public.url"]]))
    }

    /// The board-side folder refusal, reused rather than restated (`FinderDrop.isDirectory`): a
    /// folders-only drag never engages, and a package is a folder.
    @Test("A folders-only drag refuses; a mixed drag engages for its files")
    func foldersRefuse() {
        #expect(!CardWindowDrop.accepts(payloads: [["public.file-url", "public.folder"]]))
        #expect(!CardWindowDrop.accepts(payloads: [["public.file-url", "com.apple.application-bundle"]]))
        #expect(CardWindowDrop.accepts(payloads: [
            ["public.file-url", "public.folder"],
            ["public.file-url", "public.png"]
        ]))
    }

    /// The other half of the split, at the editor's end: **the text view declines the file types**,
    /// which is what lets a file drag fall through to the window-level target above it. A property
    /// read, so it is checkable without a drag.
    @MainActor
    @Test("The body editor never registers for file drags, and still takes text")
    func theEditorDeclinesFiles() {
        let textView = CardBodyTextView()
        textView.isRichText = true
        textView.isEditable = true

        let types = textView.acceptableDragTypes
        #expect(!types.contains(.fileURL))
        #expect(!types.contains(NSPasteboard.PasteboardType("NSFilenamesPboardType")))
        // What the text view *keeps* is the whole other half of the rule, and it keeps it under
        // AppKit's own spelling: an editable `NSTextView` registers the legacy `NSStringPboardType`
        // (not `public.utf8-plain-text`) and `public.url`, so both a plain-text drag and a link
        // dragged out of a browser still land at the caret as ordinary insertions.
        #expect(types.contains(NSPasteboard.PasteboardType("NSStringPboardType")))
        #expect(types.contains(.URL), "a dragged link is the editor's, not the window's")
    }
}

// MARK: - The section's pure rules

@Suite("CardAttachments ▸ selection and reveal")
struct CardAttachmentsRulesTests {

    private let names = ["a.png", "b.png", "c.png"]

    /// ↑/↓ **clamp rather than wrap**: a list is not a carousel.
    @Test("Arrows clamp at both ends")
    func arrowsClamp() {
        #expect(CardAttachments.moved("a.png", by: 1, in: names) == "b.png")
        #expect(CardAttachments.moved("c.png", by: 1, in: names) == "c.png")
        #expect(CardAttachments.moved("a.png", by: -1, in: names) == "a.png")
        #expect(CardAttachments.moved("c.png", by: -1, in: names) == "b.png")
    }

    /// With nothing selected an arrow enters the list from the edge it came from, so the section is
    /// usable the instant it takes focus.
    @Test("An arrow with nothing selected enters from the edge")
    func arrowsEnterFromTheEdge() {
        #expect(CardAttachments.moved(nil, by: 1, in: names) == "a.png")
        #expect(CardAttachments.moved(nil, by: -1, in: names) == "c.png")
        #expect(CardAttachments.moved("gone.png", by: 1, in: names) == "a.png")
        #expect(CardAttachments.moved("a.png", by: 1, in: []) == nil)
    }

    /// After a remove the selection lands on **the row that took its place** — every macOS list's
    /// behaviour, and what makes ⌫ ⌫ ⌫ work without reaching for the mouse between presses.
    @Test("A removed selection lands on the row that took its place")
    func selectionSettlesAfterARemoval() {
        #expect(CardAttachments.settle("b.png", was: names, is: ["a.png", "c.png"]) == "c.png")
        #expect(CardAttachments.settle("c.png", was: names, is: ["a.png", "b.png"]) == "b.png")
        #expect(CardAttachments.settle("b.png", was: names, is: []) == nil)
    }

    /// A selection that survived keeps its row — the common case, another window's import landing
    /// somewhere else in the list — and a reload never invents one.
    @Test("A surviving selection keeps its row, and none is never invented")
    func selectionSurvivesUnrelatedChanges() {
        #expect(CardAttachments.settle("b.png", was: names, is: ["a.png", "b.png", "c.png", "d.png"]) == "b.png")
        #expect(CardAttachments.settle(nil, was: names, is: names) == nil)
        #expect(CardAttachments.settle("gone.png", was: [], is: names) == nil)
    }

    /// The observable half of the same rule: assigning a new listing settles the selection with it,
    /// which is what keeps a snapshot from leaving the keyboard pointing at a file that is gone.
    @MainActor
    @Test("Republishing the listing settles the selection")
    func republishingSettles() {
        let attachments = CardAttachments()
        attachments.names = names
        attachments.selected = "b.png"

        attachments.names = ["a.png", "c.png"]

        #expect(attachments.selected == "c.png")
    }

    /// File ▸ Reveal in Finder's card-window scope: the selected attachment when the section has
    /// focus, the card's folder otherwise, and nothing at all for a window with no card left.
    @Test("Reveal points at the selected attachment only while the section is focused")
    func revealScope() {
        let folder = URL(fileURLWithPath: "/b/lane/card", isDirectory: true)
        let file = folder.appendingPathComponent("attachments/shot.png")

        #expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: file, isSectionFocused: true) == [file])
        #expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: file, isSectionFocused: false) == [folder])
        #expect(CardAttachments.revealURLs(cardFolder: folder, selectedURL: nil, isSectionFocused: true) == [folder])
        #expect(CardAttachments.revealURLs(cardFolder: nil, selectedURL: nil, isSectionFocused: false).isEmpty)
    }

    /// A row's URL comes from the *listing*, never from the name alone: a name the section is not
    /// showing resolves to nothing, so Open, Reveal and the drag-out all refuse together.
    @MainActor
    @Test("Only a listed name resolves to a file")
    func onlyListedNamesResolve() {
        let attachments = CardAttachments()
        attachments.cardFolder = URL(fileURLWithPath: "/b/lane/card", isDirectory: true)
        attachments.names = ["shot.png"]

        #expect(attachments.url(for: "shot.png")?.lastPathComponent == "shot.png")
        #expect(attachments.url(for: "shot.png")?.deletingLastPathComponent().lastPathComponent == "attachments")
        #expect(attachments.url(for: "gone.png") == nil)
        #expect(attachments.url(for: "sub/nested.txt") == nil)
    }
}
