import Foundation
import Testing
@testable import Kanban

/// File ▸ Share…'s staging half (`BoardShareStager`; design ruling 2026-08-09, card 72691b11).
///
/// Three promises, and all three are about what lands in the staged `.zip`:
///
/// - **`.git` is the sole exclusion** — attachments, comments and `.trash/` all carry through
///   verbatim, "a faithful copy" in the ruling's own words;
/// - **the filename is the sanitized title**, `"<Board Title>.zip"`, never the on-disk folder name
///   (the one place this app turns a freeform `title:` string into a path component);
/// - **cancellation and failure leave no residue** — the staging directory this call created is
///   the one it also removes, `BoardDuplicator`'s and `TemplateEngine`'s own rule at a third
///   boundary.
///
/// The picker (`BoardSharePresentation`) and the subprocess itself (`DittoZipArchiver`) are
/// exercised elsewhere — this suite is `stageTree(boardAt:into:isCancelled:)`, the plain
/// `FileManager` half, so the exclusion rule is provable without a subprocess anywhere in the
/// loop, plus a handful of end-to-end `stage(boardAt:titled:)` runs that do call through to
/// `ditto` (`DittoZipArchiverTests` is where that subprocess call gets its own, separate
/// verification that it actually works inside this app's sandbox).

// MARK: - Fixture

/// A board carrying everything the exclusion rule has an opinion about: `.git`, `.trash/`, a
/// comment thread with its own `.trash/`, an attachment, and a board-level stray.
private struct ShareableBoard {

    let fixture: WriterFixture
    let root: URL

    static let name = "Roadmap.kanban"

    init() throws {
        fixture = try WriterFixture()
        root = fixture.url(Self.name)
        let path = Self.name

        try fixture.item(path, "---\nschema: 1\ntitle: Roadmap\n---\nThe board's description.\n")
        try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8))

        // The sole exclusion.
        try fixture.file("\(path)/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
        try fixture.file("\(path)/.git/objects/pack/pack-1.pack", Data([0x01, 0x02]))

        // Carried: the board's own trash.
        try fixture.item("\(path)/\(BoardLoader.trashFolderName)/\(Ident.card4)",
                          Item.rich(order: "1024", title: "Thrown Away"))

        try fixture.item("\(path)/\(Ident.lane1)", Item.rich(order: "1024", title: "To Do"))
        try fixture.item("\(path)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "Starter"))
        try fixture.file("\(path)/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02, 0x03]))

        // Carried: a comment thread. Excluded regardless (unconditionally, by `BoardTreeCopy`
        // itself): the comment thread's own `.trash/`.
        try fixture.item(
            "\(path)/\(Ident.lane1)/\(Ident.card1)/comments/\(Ident.card2)",
            "---\nschema: 1\nkind: comment\nauthor: tester\n---\nA comment.\n"
        )
        try fixture.item(
            "\(path)/\(Ident.lane1)/\(Ident.card1)/comments/.trash/\(Ident.card3)",
            "---\nschema: 1\nkind: comment\nauthor: tester\n---\nA deleted comment.\n"
        )
    }

    func tearDown() { fixture.tearDown() }
}

/// Every path under `root`, root-relative, hidden entries included.
private func tree(of root: URL) -> Set<String> {
    var paths: Set<String> = []
    let walker = FileManager.default.enumerator(at: root, includingPropertiesForKeys: nil, options: [])
    while let url = walker?.nextObject() as? URL {
        paths.insert(url.path.replacingOccurrences(of: root.path + "/", with: ""))
    }
    return paths
}

private func shareFailure(_ operation: () throws -> Void) -> BoardShareStager.Failure? {
    do {
        try operation()
        Issue.record("expected the share to fail, but it succeeded")
        return nil
    } catch let failure as BoardShareStager.Failure {
        return failure
    } catch {
        Issue.record("expected a BoardShareStager.Failure, got \(error)")
        return nil
    }
}

// MARK: - Naming

@Suite("BoardShareStager — the filename")
struct BoardShareStagerNamingTests {

    @Test("An ordinary title needs no changes")
    func ordinaryTitlePassesThrough() {
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap") == "Roadmap")
    }

    @Test("A slash is the one byte the filesystem actually forbids, and it is replaced")
    func slashIsReplaced() {
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "Q1/Q2 Plan") == "Q1-Q2 Plan")
    }

    @Test("A colon is legal on disk but reads as a path separator in Finder, so it goes too")
    func colonIsReplaced() {
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "Roadmap: 2026") == "Roadmap- 2026")
    }

    @Test("Surrounding whitespace is trimmed")
    func whitespaceIsTrimmed() {
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "  Roadmap  ") == "Roadmap")
    }

    @Test("An empty or all-whitespace title falls back to 'Board'")
    func emptyTitleFallsBack() {
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "") == "Board")
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "   ") == "Board")
        // Made entirely of the one replaced character, and nothing left once it's swapped for '-'.
        #expect(BoardShareStager.sanitizedFilenameComponent(from: "/") == "-")
    }
}

// MARK: - The tree copy's exclusion rule

@Suite("BoardShareStager — the tree copy")
struct BoardShareStagerTreeTests {

    @Test("`.git` is the sole exclusion — everything else, trash and comments included, rides along")
    func onlyGitIsExcluded() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let destination = board.fixture.url("staged")
        try BoardTreeCopy.createDirectory(at: destination)

        try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })

        let paths = tree(of: destination)
        #expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") },
                "inert history nobody receiving a shared board asked for")
        #expect(paths.contains { $0.hasPrefix("\(BoardLoader.trashFolderName)/") },
                "a share is a faithful copy — Duplicate's posture, not Save as Template's")
        #expect(paths.contains { $0.hasSuffix("attachments/shot.png") })
        #expect(paths.contains { $0.hasSuffix("comments/\(Ident.card2)/index.md") })
        #expect(paths.contains("CLAUDE.user.md"))
        #expect(!paths.contains { $0.contains("comments/.trash") },
                "the comment thread's own trash never travels — BoardTreeCopy's unconditional rule")
    }

    @Test("The copy is byte for byte")
    func contentIsVerbatim() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let destination = board.fixture.url("staged")
        try BoardTreeCopy.createDirectory(at: destination)

        try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })

        #expect(try Data(contentsOf: destination.appendingPathComponent("CLAUDE.user.md"))
                    == Data("board instructions\n".utf8))
        #expect(try Data(contentsOf: destination.appendingPathComponent(
            "\(Ident.lane1)/\(Ident.card1)/attachments/shot.png"
        )) == Data([0x01, 0x02, 0x03]))
    }

    @Test("The board being shared is never written to")
    func originalIsUntouched() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let before = tree(of: board.root)
        let destination = board.fixture.url("staged")
        try BoardTreeCopy.createDirectory(at: destination)

        try BoardShareStager.stageTree(boardAt: board.root, into: destination, isCancelled: { false })

        #expect(tree(of: board.root) == before)
    }
}

// MARK: - End to end

@Suite("BoardShareStager — stage(boardAt:titled:)")
struct BoardShareStagerEndToEndTests {

    @Test("The zip lands named from the title, not the folder")
    func zipIsNamedFromTheTitle() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }

        let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Q1 Roadmap")
        defer { BoardShareStager.cleanup(archive) }

        #expect(archive.zipURL.lastPathComponent == "Q1 Roadmap.zip",
                "the board's own display title — the folder is 'Roadmap.kanban', the title is not")
        #expect(FileManager.default.fileExists(atPath: archive.zipURL.path))
    }

    @Test("The decompressed tree does not survive the zip — one file is left for the picker")
    func onlyTheZipRemains() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }

        let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap")
        defer { BoardShareStager.cleanup(archive) }

        let entries = try FileManager.default.contentsOfDirectory(atPath: archive.root.path)
        #expect(
            entries == ["Roadmap.zip"],
            "the intermediate decompressed copy — trash and attachments included — must not sit in temp storage for the picker's whole lifetime"
        )
    }

    @Test("cleanup(_:) removes the whole staging directory")
    func cleanupRemovesEverything() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let archive = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap")
        #expect(FileManager.default.fileExists(atPath: archive.root.path))

        BoardShareStager.cleanup(archive)

        #expect(!FileManager.default.fileExists(atPath: archive.root.path))
    }

    @Test("A share cancelled before it began leaves no staging directory at all")
    func cancellingBeforeTheFirstItemCreatesNothing() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)

        let failure = shareFailure {
            _ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: { true })
        }

        #expect(failure == .cancelled)
        #expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before,
                "no 'dev.rzen.indie.Kanban-share-…' folder survives — a cancelled share never happened")
    }

    @Test("Cancelling mid-walk removes the partial staging directory")
    func cancellingMidWalkRemovesThePartial() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)

        var reads = 0
        let failure = shareFailure {
            _ = try BoardShareStager.stage(boardAt: board.root, titled: "Roadmap", isCancelled: {
                reads += 1
                return reads > 3
            })
        }

        #expect(failure == .cancelled)
        #expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before)
    }

    @Test("A board that isn't there fails as a share, naming the operation, and leaves nothing behind")
    func missingSourceFailsInTheShareVocabulary() throws {
        let board = try ShareableBoard()
        defer { board.tearDown() }
        let missing = board.fixture.url("Never.kanban")
        let before = try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path)

        let failure = shareFailure {
            _ = try BoardShareStager.stage(boardAt: missing, titled: "Never")
        }

        guard case let .failed(error) = failure else {
            Issue.record("expected an ordinary failure, got \(String(describing: failure))")
            return
        }
        #expect(error.operation == .shareBoard(title: "Never"),
                "the user pressed Share; the banner must say so")
        #expect(BannerCenter.headline(for: error).hasPrefix("Couldn't share 'Never'"))
        #expect(try FileManager.default.contentsOfDirectory(atPath: board.fixture.root.path) == before,
                "a half-staged share is residue — it goes, and the banner is what remains")
    }
}

// MARK: - Menu validation

@Suite("ShareBoardCommand — validation")
struct ShareBoardCommandValidationTests {

    @Test("Needs both a focused board window and its session ref")
    func needsStoreAndRef() {
        #expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: false, isEditingInline: false))
        #expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: false, isEditingInline: false))
        #expect(!ShareBoardCommand.isEnabled(hasStore: false, hasRef: true, isEditingInline: false))
        #expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
    }

    @Test("An open inline title editor holds a pending change no flush can reach")
    func inlineEditingDisablesIt() {
        #expect(!ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: true))
    }

    @Test("Unlike Duplicate and Save as Template, the read-only lock never gates it — a share is a read")
    func theLockIsNeverConsulted() {
        // `isEnabled` takes no lock parameter at all — this test pins that omission as
        // deliberate: adding one back would be the change to catch here, not a passing assertion.
        #expect(ShareBoardCommand.isEnabled(hasStore: true, hasRef: true, isEditingInline: false))
    }
}
