import Foundation
import Testing
@testable import Kanban

/// **Comment attachments author in-window** (05-card-window.md ▸ The comments column, ruled
/// 2026-07-29): a file dropped in the composer lands in the draft's `attachments/`, one dropped in an
/// inline edit session lands in that comment's, and an authoring chip's Remove moves the file to the
/// *system* Trash.
///
/// This suite is the **write** half — the two primitives and the shape guard that decides which
/// folders are authoring surfaces at all — plus the draft *read* the composer restores from. The
/// hover rule that chooses between them is pure and lives in `CardCommentsLayoutTests`; the pane's
/// wiring is `CardCommentsTests`'.
///
/// Assertions are against the bytes on disk, `CommentWriteTests`' rule.

private let cardTitle = "Fix login"

// MARK: - Reading the draft

@Suite("Comments ▸ reading the draft")
struct CommentDraftReadTests {

    @Test("The composer's restore is the read: body and chips, straight off the folder")
    func loadDraftReadsBodyAndChips() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText(body: "half a thought\n"))
        try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))

        let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card)))
        #expect(draft.body == "half a thought\n")
        #expect(draft.attachments == ["shot.png"])
        #expect(!draft.isEmpty)
    }

    @Test("A card with no draft has nothing to restore")
    func noDraftIsNil() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)

        #expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil)
    }

    @Test("A draft folder with no readable index.md still reports its files")
    func anIndexlessDraftStillHasChips() throws {
        // The two-step-create shape, and the shape a drop-before-a-keystroke leaves. Reporting `nil`
        // would hide files the user can see in Finder.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))

        let draft = try #require(CommentThread.loadDraft(inCard: fixture.url(card)))
        #expect(draft.body == "")
        #expect(draft.attachments == ["shot.png"])
        #expect(!draft.isEmpty, "a draft with no text but a file in it is still a draft")
    }

    @Test("Emptiness is the Writer's own gate, asked forwards")
    func emptinessMirrorsTheDeleteRule() {
        #expect(CommentDraft(body: "", attachments: []).isEmpty)
        #expect(CommentDraft(body: "  \n ", attachments: []).isEmpty)
        #expect(!CommentDraft(body: "", attachments: ["shot.png"]).isEmpty)
        #expect(!CommentDraft(body: "x", attachments: []).isEmpty)
    }

    @Test("A .draft held by a file is not a draft")
    func aSquattedDraftNameIsNotADraft() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.file("\(card)/comments/.draft", Data("not a folder".utf8))

        #expect(CommentThread.loadDraft(inCard: fixture.url(card)) == nil)
    }
}

// MARK: - Importing

@Suite("Comments ▸ authoring attachments")
struct CommentAttachmentImportTests {

    private func source(_ fixture: WriterFixture, named name: String, _ text: String = "x") throws -> URL {
        try fixture.file("sources/\(name)", Data(text.utf8))
    }

    @Test("A drop in the composer lands in the draft's attachments/")
    func importsIntoTheDraft() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())
        let file = try source(fixture, named: "shot.png")

        let landed = try BoardWriter.importCommentAttachments(
            [file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
        )
        #expect(landed.map(\.fileName) == ["shot.png"])
        #expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
    }

    @Test("A drop in an inline edit session lands in that comment's attachments/")
    func importsIntoAComment() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let file = try source(fixture, named: "shot.png")

        _ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path))
        #expect(fixture.exists("\(path)/attachments/shot.png"))
    }

    @Test("A name already taken is renamed Finder-style, never overwritten")
    func collisionsRenameFinderStyle() throws {
        // The card level's rule, shared rather than restated: `importFiles` is one implementation, so
        // a file added to a comment behaves exactly like one added to a card.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())
        try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("first".utf8))
        let file = try source(fixture, named: "shot.png", "second")

        let landed = try BoardWriter.importCommentAttachments(
            [file], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
        )
        #expect(landed.map(\.fileName) == ["shot 2.png"])
        #expect(try fixture.data("\(card)/comments/.draft/attachments/shot.png") == Data("first".utf8))
    }

    @Test("Importing opens no index.md — a file says nothing about the text beside it")
    func importStampsNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let path = commentPath(CommentIdent.one, inCard: card)
        try fixture.item(path, commentText())
        let before = try fixture.indexText(path)
        let file = try source(fixture, named: "shot.png")

        _ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(path))
        #expect(try fixture.indexText(path) == before, "the relocation rule: no stamp for a file move")
    }

    @Test("A folder is refused, as it is at card level")
    func foldersAreRefused() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())
        let folder = fixture.url("sources/afolder")
        try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)

        let failure = writeFailure {
            _ = try BoardWriter.importCommentAttachments(
                [folder], intoComment: CommentThread.draftFolder(inCard: fixture.url(card))
            )
        }
        #expect(failure?.operation == .importAttachment(filename: "afolder"))
    }

    @Test("comments/.trash/ is not an authoring surface")
    func theTrashRefusesAnImport() throws {
        // A deleted comment is undo's backing store and "never a UI surface" (01-storage-format.md §
        // Enhanced schema), so there is no gesture that could aim at one — and the Writer says so
        // rather than trusting that.
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.trash/\(CommentIdent.one)", commentText())
        let file = try source(fixture, named: "shot.png")

        let failure = writeFailure {
            _ = try BoardWriter.importCommentAttachments(
                [file],
                intoComment: CommentThread.trashedCommentFolder(
                    ItemID(rawValue: CommentIdent.one), inCard: fixture.url(card)
                )
            )
        }
        #expect(failure != nil)
    }

    @Test("A card folder is not a comment folder either")
    func theCardIsRefused() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        let file = try source(fixture, named: "shot.png")

        let failure = writeFailure {
            _ = try BoardWriter.importCommentAttachments([file], intoComment: fixture.url(card))
        }
        #expect(failure != nil)
    }
}

// MARK: - Removing

@Suite("Comments ▸ removing an authoring chip")
struct CommentAttachmentRemoveTests {

    @Test("Remove moves the file to the system Trash — never a hard delete")
    func removeTrashesTheFile() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())
        try fixture.file("\(card)/comments/.draft/attachments/shot.png", Data("png".utf8))

        let trashed = try BoardWriter.removeCommentAttachment(
            named: "shot.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card))
        )
        let landing = try #require(trashed)
        #expect(FileManager.default.fileExists(atPath: landing.path), "the file still exists, in the Trash")
        #expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
        try? FileManager.default.removeItem(at: landing)
    }

    @Test("A name that is no longer there is not a failure")
    func aVanishedNameIsANoOp() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())

        #expect(try BoardWriter.removeCommentAttachment(
            named: "gone.png", fromComment: CommentThread.draftFolder(inCard: fixture.url(card))
        ) == nil)
    }
}

// MARK: - The store bracket

@MainActor
@Suite("Comments ▸ authoring attachments at the store")
struct CommentAttachmentStoreTests {

    @Test("The store's import and remove land through the ordinary bracket")
    func theStoreBracketsBoth() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText())
        let file = try fixture.file("sources/shot.png", Data("png".utf8))
        let store = try BoardStore(rootURL: fixture.root)
        let cardID = ItemID(rawValue: Ident.card1)

        store.importCommentAttachments([file], inCard: cardID, target: .draft)
        #expect(fixture.exists("\(card)/comments/.draft/attachments/shot.png"))

        store.removeCommentAttachment(named: "shot.png", inCard: cardID, target: .draft)
        #expect(!fixture.exists("\(card)/comments/.draft/attachments/shot.png"))
    }

    @Test("A card that is not on the board writes nothing")
    func aVanishedCardWritesNothing() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try makeCommentBoard(fixture)
        let file = try fixture.file("sources/shot.png", Data("png".utf8))
        let store = try BoardStore(rootURL: fixture.root)

        store.importCommentAttachments([file], inCard: ItemID(rawValue: Ident.card4), target: .draft)
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card4)"))
    }

    @Test("The draft read reaches the store the same way the thread read does")
    func theStoreReadsTheDraft() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let card = try makeCommentBoard(fixture)
        try fixture.item("\(card)/comments/.draft", commentText(body: "restored\n"))
        let store = try BoardStore(rootURL: fixture.root)

        #expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card1))?.body == "restored\n")
        #expect(store.commentDraft(inCard: ItemID(rawValue: Ident.card4)) == nil, "a card that is not there")
    }
}
