import Foundation
import Testing
@testable import Kanban

/// **The disk half of File ▸ Import Board…** (15-import-export.md) — a parsed `InterchangeBoard`
/// materialized into a real `.kanban` folder, read back through the ordinary loader.
///
/// The pure half (parsers, serializers, detection) is `InterchangeTests.swift`. What this file pins is
/// everything that only becomes true once the Writer has run: the rank ladder the create path mints, the
/// board conventions an import gets for free, and the construct-then-clean atomicity it borrows from
/// `TemplateEngine`.

@Suite("Board import — materialization")
struct BoardImportWriteTests {

    // MARK: Fixture

    /// A scratch directory, removed on the way out.
    private struct Scratch {
        let root: URL

        init() throws {
            root = FileManager.default.temporaryDirectory
                .appendingPathComponent("BoardImportTests-\(UUID().uuidString)", isDirectory: true)
            try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)
        }

        func tearDown() {
            try? FileManager.default.removeItem(at: root)
        }

        func url(_ name: String) -> URL {
            root.appendingPathComponent(name, isDirectory: true)
        }
    }

    private func sample() -> InterchangeBoard {
        InterchangeBoard(title: "Parsed Title", lanes: [
            InterchangeLane(title: "To Do", cards: [
                InterchangeCard(title: "Fix login", body: "The button does nothing."),
                InterchangeCard(title: "Ship the beta"),
                InterchangeCard(title: nil, body: "an untitled card")
            ]),
            InterchangeLane(title: "Done")
        ])
    }

    // MARK: Tests

    @Test("An imported board loads as an ordinary board, in parse order")
    func materializedBoardLoads() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Imported.kanban")
        try BoardImporter.materialize(sample(), to: destination, title: "Imported")

        let model = try BoardLoader.load(boardRoot: destination).model
        #expect(model.schema == BoardLoader.supportedSchema)
        // **The save panel's name wins over the parsed one** (01-storage-format.md § Board naming).
        #expect(model.title.value == "Imported")

        #expect(model.lanes.map(\.title.value) == ["To Do", "Done"])
        #expect(model.lanes[0].cards.map(\.title.value) == ["Fix login", "Ship the beta", nil])
        #expect(model.lanes[0].cards[0].body == "The button does nothing.")
        #expect(model.lanes[0].cards[1].body.isEmpty)
        #expect(model.lanes[0].cards[2].body == "an untitled card")
        #expect(model.lanes[1].cards.isEmpty)
    }

    @Test("Ranks are the create path's own 1024 ladder, in parse order")
    func ranksAreMintedWithGaps() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Ranks.kanban")
        try BoardImporter.materialize(sample(), to: destination, title: "Ranks")

        let model = try BoardLoader.load(boardRoot: destination).model
        #expect(model.lanes.map(\.order) == [1024, 2048])
        #expect(model.lanes[0].cards.map(\.order) == [1024, 2048, 3072])
    }

    @Test("Folder names are fresh lowercase UUIDs at every level")
    func identitiesAreMinted() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Ids.kanban")
        try BoardImporter.materialize(sample(), to: destination, title: "Ids")

        let model = try BoardLoader.load(boardRoot: destination).model
        var seen = Set<String>()
        for lane in model.lanes {
            #expect(BoardLoader.isUUIDShaped(lane.id.rawValue))
            #expect(lane.id.rawValue == lane.id.rawValue.lowercased())
            #expect(seen.insert(lane.id.rawValue).inserted)
            for card in lane.cards {
                #expect(BoardLoader.isUUIDShaped(card.id.rawValue))
                #expect(card.id.rawValue == card.id.rawValue.lowercased())
                #expect(seen.insert(card.id.rawValue).inserted)
            }
        }
    }

    @Test("An imported board is born with the conventions every new board gets")
    func bornWithTheUsualFurniture() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Furniture.kanban")
        try BoardImporter.materialize(sample(), to: destination, title: "Furniture")

        let manager = FileManager.default
        #expect(manager.fileExists(atPath: destination.appendingPathComponent(".gitignore").path))
        #expect(manager.fileExists(atPath: destination.appendingPathComponent("CLAUDE.md").path))
        #expect(!manager.fileExists(atPath: destination.appendingPathComponent(".trash").path))
    }

    @Test("An occupied destination is refused, never clobbered")
    func occupiedDestinationRefuses() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Taken.kanban")
        try FileManager.default.createDirectory(at: destination, withIntermediateDirectories: true)
        let marker = destination.appendingPathComponent("mine.txt")
        try Data("keep me".utf8).write(to: marker)

        #expect(throws: BoardImporter.Failure.self) {
            try BoardImporter.materialize(sample(), to: destination, title: "Taken")
        }
        // The refusal never touched what was there.
        #expect(try Data(contentsOf: marker) == Data("keep me".utf8))
        #expect(!FileManager.default.fileExists(atPath: destination.appendingPathComponent("index.md").path))
    }

    @Test("A cancelled import leaves nothing behind")
    func cancelledImportRemovesThePartial() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Cancelled.kanban")
        // False for the pre-flight and the first lane, true from the first card on — so the board
        // folder and one lane really are on disk when the cancel lands.
        var calls = 0
        let isCancelled = { () -> Bool in
            calls += 1
            return calls > 2
        }

        var caught: BoardImporter.Failure?
        do {
            try BoardImporter.materialize(sample(), to: destination, title: "Cancelled", isCancelled: isCancelled)
        } catch {
            caught = error
        }
        #expect(caught == .cancelled)
        #expect(!FileManager.default.fileExists(atPath: destination.path))
    }

    @Test("A board with no lanes still materializes as a board")
    func emptyBoardMaterializes() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let destination = scratch.url("Empty.kanban")
        try BoardImporter.materialize(InterchangeBoard(), to: destination, title: "Empty")

        let model = try BoardLoader.load(boardRoot: destination).model
        #expect(model.lanes.isEmpty)
        #expect(model.title.value == "Empty")
    }

    // MARK: Reading

    @Test("A file read end to end lands as the board its bytes describe")
    func readParsesTheFile() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let file = scratch.root.appendingPathComponent("Team Board.md")
        try Data("---\n\nkanban-plugin: board\n\n---\n\n## To Do\n\n- [ ] A\n".utf8).write(to: file)

        let source = try BoardImporter.read(contentsOf: file)
        #expect(source.format == .obsidianKanban)
        #expect(source.title == "Team Board")
        #expect(source.board.lanes.map(\.title) == ["To Do"])
    }

    @Test("Bytes that are not UTF-8 are refused with a sentence, not imported as mojibake")
    func nonUTF8IsRefused() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let file = scratch.root.appendingPathComponent("latin.csv")
        try Data([0xFF, 0xFE, 0x41, 0x00]).write(to: file)

        var caught: BoardImporter.Failure?
        do {
            _ = try BoardImporter.read(contentsOf: file)
        } catch {
            caught = error
        }
        guard case let .failed(error) = caught else {
            Issue.record("expected a failure, got \(String(describing: caught))")
            return
        }
        #expect(error.operation == .importBoard(fileName: "latin.csv"))
        #expect(BannerCenter.headline(for: error).hasPrefix("Couldn't import 'latin.csv'"))
    }

    @Test("A missing file is a failure, not a crash")
    func missingFileIsRefused() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        #expect(throws: BoardImporter.Failure.self) {
            _ = try BoardImporter.read(contentsOf: scratch.root.appendingPathComponent("nope.md"))
        }
    }

    // MARK: The whole trip

    @Test("Export a real board, import it back, and the lanes and cards survive")
    func exportThenImportThroughDisk() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        // Build a real board the ordinary way, so the export reads a genuine snapshot.
        let origin = scratch.url("Origin.kanban")
        try BoardWriter.createBoard(at: origin, title: "Origin")
        let todo = try BoardWriter.createLane(inBoard: origin, title: "To Do")
        let done = try BoardWriter.createLane(inBoard: origin, title: "Done")
        let first = try BoardWriter.createCard(
            inLane: origin.appendingPathComponent(todo.rawValue, isDirectory: true),
            title: "Fix login"
        )
        try BoardWriter.writeBody(
            inItemFolder: origin
                .appendingPathComponent(todo.rawValue, isDirectory: true)
                .appendingPathComponent(first.rawValue, isDirectory: true),
            body: "line one\nline two"
        )
        _ = try BoardWriter.createCard(
            inLane: origin.appendingPathComponent(todo.rawValue, isDirectory: true),
            title: "Ship the beta"
        )
        _ = try BoardWriter.createCard(
            inLane: origin.appendingPathComponent(done.rawValue, isDirectory: true),
            title: "Write the changelog"
        )

        let snapshot = try BoardLoader.load(boardRoot: origin).model
        #expect(snapshot.lanes.map(\.title.value) == ["To Do", "Done"], "the fixture board itself")

        for format in InterchangeFormat.allCases {
            let exported = BoardExporter.text(
                for: InterchangeBoard.from(snapshot, titled: "Origin"),
                format: format
            )
            let file = scratch.root.appendingPathComponent("Origin-\(format.rawValue).\(format.fileExtension)")
            try BoardExporter.write(exported, to: file, boardTitle: "Origin")

            let source = try BoardImporter.read(contentsOf: file)
            #expect(source.format == format)

            let destination = scratch.url("Back-\(format.rawValue).kanban")
            try BoardImporter.materialize(source.board, to: destination, title: "Back")
            let reloaded = try BoardLoader.load(boardRoot: destination).model

            // CSV has no row for an empty lane, and this board has none — every lane here carries
            // cards, so all three formats must reproduce the whole board.
            #expect(reloaded.lanes.map(\.title.value) == ["To Do", "Done"], "\(format.rawValue) lanes")
            #expect(
                reloaded.lanes.map { $0.cards.map(\.title.value) }
                    == [["Fix login", "Ship the beta"], ["Write the changelog"]],
                "\(format.rawValue) cards, in order"
            )
            #expect(
                reloaded.lanes.first?.cards.first?.body == "line one\nline two",
                "\(format.rawValue) body"
            )
        }
    }

    @Test("The omissions count reads comments and attachments off the snapshot")
    func omissionsCounted() throws {
        let scratch = try Scratch()
        defer { scratch.tearDown() }

        let root = scratch.url("Counted.kanban")
        try BoardWriter.createBoard(at: root, title: "Counted")
        let lane = try BoardWriter.createLane(inBoard: root, title: "L")
        let laneFolder = root.appendingPathComponent(lane.rawValue, isDirectory: true)
        let card = try BoardWriter.createCard(inLane: laneFolder, title: "A")
        let cardFolder = laneFolder.appendingPathComponent(card.rawValue, isDirectory: true)

        #expect(InterchangeOmissions.of(try BoardLoader.load(boardRoot: root).model).isEmpty)

        // Two attachments and one comment, written the way the schema spells them.
        let attachments = cardFolder.appendingPathComponent("attachments", isDirectory: true)
        try FileManager.default.createDirectory(at: attachments, withIntermediateDirectories: true)
        try Data("a".utf8).write(to: attachments.appendingPathComponent("one.txt"))
        try Data("b".utf8).write(to: attachments.appendingPathComponent("two.txt"))

        let comment = cardFolder
            .appendingPathComponent("comments", isDirectory: true)
            .appendingPathComponent(UUID().uuidString.lowercased(), isDirectory: true)
        try FileManager.default.createDirectory(at: comment, withIntermediateDirectories: true)
        try Data("---\nschema: 1\nkind: comment\n---\nhello\n".utf8)
            .write(to: comment.appendingPathComponent("index.md"))

        let omissions = InterchangeOmissions.of(try BoardLoader.load(boardRoot: root).model)
        #expect(omissions == InterchangeOmissions(comments: 1, attachments: 2))
        #expect(BannerCenter.exportOmissionsMessage(omissions, format: .csv)
            == "Exported without 1 comment and 2 attachments — CSV carries neither")
    }
}
