import Foundation
import Testing
@testable import Kanban

/// **Save as Template** — the board copied into the user templates store, on disk (09-templates.md
/// ▸ Save as Template).
///
/// Every promise 09 makes about that copy is an assertion about bytes in a store:
///
/// - **`.git` and `.trash/` are dropped**, and nothing else is — strays, `CLAUDE.user.md`, a seeded
///   `.gitignore`, attachments and bodies all carry through;
/// - **GUIDs and timestamps are kept** — a template is a fork, and both are inert anyway because
///   instantiation remints and restamps at its own boundary;
/// - **a `template:` key lands on the copy**, with an order appended after the existing user
///   templates, overwriting a stale one the board carried in;
/// - **collisions auto-rename, Finder-style** — never overwritten, never refused;
/// - and **the app never stamps a key into store files it didn't write itself**: listing and
///   instantiating a hand-dropped board leave it exactly as its author left it.
///
/// Plus the promise that makes the copy safe to run at all: a cancelled save leaves nothing behind.
///
/// Every test drives an explicit store URL. **No test may touch the real store** — it is the
/// developer's own. `userStore` is named here only to prove the engine never creates it on its own.

// MARK: - Helpers

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

/// Every path under `root`, root-relative, hidden entries included.
private func storeTree(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 names(in folder: URL) throws -> Set<String> {
    Set(try FileManager.default.contentsOfDirectory(atPath: folder.path))
}

// MARK: - The board being saved

/// A board carrying everything 09 has a rule about: the two exclusions, board- and lane-level
/// strays, an attachment, a stale `template:` key from its own instantiation, and old timestamps
/// with someone else's attribution on them.
private struct SavableBoard {

    let fixture: WriterFixture
    let root: URL
    let store: URL

    static let name = "Roadmap.kanban"

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

        try fixture.item(path, """
        ---
        schema: 1
        title: Roadmap
        icon: map
        iconColor: fern
        template: {order: 800}
        project: lanework
        created: 2026-01-01T09:00:00Z
        modified: 2026-02-02T09:00:00Z
        modified-by: claude
        ---
        The board's description, which becomes the template's blurb.
        """)

        // Strays, at board and lane level — "the copy is literal apart from the stated exclusions".
        try fixture.file("\(path)/CLAUDE.user.md", Data("board instructions\n".utf8))
        try fixture.file("\(path)/.gitignore", Data(".DS_Store\n".utf8))

        // The two exclusions.
        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]))
        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]))
        try fixture.item("\(path)/\(Ident.lane2)", Item.rich(order: "2048", title: "Done"))
        try fixture.file("\(path)/\(Ident.lane2)/notes/scratch.md", Data("scratch\n".utf8))
    }

    func tearDown() { fixture.tearDown() }

    @discardableResult
    func save(isCancelled: () -> Bool = { false }) throws -> URL {
        try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Roadmap", into: store, isCancelled: isCancelled)
    }
}

// MARK: - The copy

@Suite("Save as Template — the copy")
struct SaveAsTemplateCopyTests {

    @Test("The store is created by the first save — the engine names it, it doesn't make it early")
    func theFirstSaveMintsTheStore() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        #expect(!FileManager.default.fileExists(atPath: board.store.path))

        let landed = try board.save()

        #expect(FileManager.default.fileExists(atPath: board.store.path))
        #expect(landed == board.store.appendingPathComponent("Roadmap.kanban", isDirectory: true))
    }

    @Test("`.git` and `.trash/` are the two exclusions, and they are the only ones")
    func theTwoExclusionsAreExcluded() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let landed = try board.save()

        let paths = storeTree(of: landed)
        #expect(!paths.contains { $0 == ".git" || $0.hasPrefix(".git/") },
                "a template is content, not history — the board's whole repo must not land in the store")
        #expect(!paths.contains { $0 == ".trash" || $0.hasPrefix(".trash/") },
                "a template isn't a fork; the board's trash is not part of what was saved")
        #expect(paths.contains("CLAUDE.user.md"))
        #expect(paths.contains(".gitignore"), "the exclusions are exact names, not prefixes")
        #expect(paths.contains { $0.hasSuffix("notes/scratch.md") })
        #expect(paths.contains { $0.hasSuffix("attachments/shot.png") })
    }

    @Test("Strays and attachments arrive byte for byte")
    func straysAreVerbatim() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let landed = try board.save()

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

    @Test("GUIDs are kept — a whole-board copy is a new namespace, and instantiation remints anyway")
    func identitiesAreKept() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let landed = try board.save()

        #expect(try names(in: landed).isSuperset(of: [Ident.lane1, Ident.lane2]))
        #expect(try names(in: landed.appendingPathComponent(Ident.lane1)).contains(Ident.card1))
    }

    @Test("Timestamps and foreign attributions are kept — the copies-keep-created rule, inertly")
    func timestampsAreUntouched() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        let cardBefore = try Data(contentsOf: board.root
            .appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/index.md"))

        let landed = try board.save()

        // A card's index.md is copied by the file system and never rewritten: identical bytes, so
        // `created`, `modified` and `modified-by` are all exactly as they were.
        #expect(try Data(contentsOf: landed.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)/index.md"))
                    == cardBefore)

        // The board's own index.md *is* rewritten (the `template:` key), so it is asserted by value.
        let model = try BoardLoader.load(boardRoot: landed).model
        #expect(model.created.value == ISO8601DateFormatter().date(from: "2026-01-01T09:00:00Z"))
        #expect(model.title.value == "Roadmap")
    }

    @Test("The board being saved is never written to")
    func theSourceBoardIsUntouched() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        let before = storeTree(of: board.root)
        let indexBefore = try Data(contentsOf: board.root.appendingPathComponent("index.md"))

        try board.save()

        #expect(storeTree(of: board.root) == before)
        #expect(try Data(contentsOf: board.root.appendingPathComponent("index.md")) == indexBefore,
                "the key is stamped on the copy — the user's board never gains one")
    }

    @Test("The saved template loads, and instantiates into an ordinary board")
    func theSavedTemplateRoundTrips() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let landed = try board.save()
        let rows = TemplateEngine.userRows(in: board.store)
        let template = try #require(rows.first?.template)
        #expect(template.url == landed)
        #expect(template.name == "Roadmap")
        #expect(template.blurb == "The board's description, which becomes the template's blurb.")

        let destination = board.fixture.url("New Board.kanban")
        try TemplateEngine.instantiate(template: template, to: destination, title: "New Board")
        let result = try BoardLoader.load(boardRoot: destination)
        #expect(result.model.lanes.map { $0.title.value } == ["To Do", "Done"])
        #expect(result.model.lanes.flatMap(\.cards).map { $0.title.value } == ["Starter"])
        #expect(result.model.title.value == "New Board")
        // The board's own lane-level stray (`Done/notes/`) is still a stray after two copies — which
        // is the point: it survived both, unread and unrendered, exactly as 09 promises.
        #expect(result.warnings.count == 1)
    }
}

// MARK: - The key

@Suite("Save as Template — the template: key")
struct SaveAsTemplateKeyTests {

    @Test("The first user template takes the first order")
    func firstSaveTakesTheFirstOrder() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let landed = try board.save()

        #expect(try BoardLoader.load(boardRoot: landed).model.template
                    == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(100))]))
    }

    @Test("A stale order carried in from the board's own instantiation is overwritten")
    func staleOrderIsOverwritten() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        // The fixture board carries `template: {order: 800}` — Roadmap's bundled position, which is
        // exactly the residue 09 describes ("it was itself instantiated from a template").
        #expect(try BoardLoader.load(boardRoot: board.root).model.template
                    == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(800))]))

        let landed = try board.save()

        let order = try #require(TemplateEngine.rows(in: board.store, origin: .user).first?.order)
        #expect(order == 100, "the stale order is replaced, not respected")
        #expect(try BoardLoader.load(boardRoot: landed).model.document.value(for: "project") == .string("lanework"),
                "the surgical edit leaves every other key where it was")
    }

    @Test("Each save is appended after the existing user templates")
    func ordersAreAppended() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        try board.save()
        try board.save()
        try board.save()

        let rows = TemplateEngine.userRows(in: board.store)
        #expect(rows.map(\.order) == [100, 200, 300])
        #expect(rows.map(\.name) == ["Roadmap", "Roadmap", "Roadmap"])
    }

    @Test("A hand-dropped keyless board is counted as existing but never given a key")
    func aKeylessNeighbourIsNeverStamped() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        try FileManager.default.createDirectory(at: board.store, withIntermediateDirectories: true)
        let dropped = board.store.appendingPathComponent("Hand Dropped.kanban", isDirectory: true)
        try FileManager.default.createDirectory(
            at: dropped.appendingPathComponent(Ident.lane3, isDirectory: true),
            withIntermediateDirectories: true
        )
        try Data("---\nschema: 1\ntitle: Hand Dropped\n---\nMine.\n".utf8)
            .write(to: dropped.appendingPathComponent("index.md"))
        try Data("---\nschema: 1\ntitle: Lane\norder: 1024\n---\n".utf8)
            .write(to: dropped.appendingPathComponent("\(Ident.lane3)/index.md"))
        let droppedBytes = try Data(contentsOf: dropped.appendingPathComponent("index.md"))

        // Listing it, saving beside it, and instantiating from it: three reads, no writes.
        let template = try #require(TemplateEngine.userRows(in: board.store).first?.template)
        try board.save()
        try TemplateEngine.instantiate(
            template: template,
            to: board.fixture.url("From Dropped.kanban"),
            title: "From Dropped"
        )

        #expect(try Data(contentsOf: dropped.appendingPathComponent("index.md")) == droppedBytes,
                "the app never stamps a key into store files it didn't write itself")
        #expect(TemplateEngine.userRows(in: board.store).first { $0.name == "Hand Dropped" }?.order == nil)
    }

    @Test("A keyless neighbour contributes no order, so the first save still takes the first one")
    func keylessNeighboursDoNotMoveTheLadder() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        try FileManager.default.createDirectory(at: board.store, withIntermediateDirectories: true)
        let dropped = board.store.appendingPathComponent("Hand Dropped.kanban", isDirectory: true)
        try FileManager.default.createDirectory(at: dropped, withIntermediateDirectories: true)
        try Data("---\nschema: 1\ntitle: Hand Dropped\n---\n".utf8)
            .write(to: dropped.appendingPathComponent("index.md"))

        let landed = try board.save()

        #expect(try BoardLoader.load(boardRoot: landed).model.template
                    == .mapping([YAMLValue.Pair(key: .string("order"), value: .int(100))]))
    }

    @Test("nextUserOrder reads the store's own highest key, not a counter")
    func nextOrderFollowsHandEdits() throws {
        let fixture = try TemplateFixture(named: "Ladder")
        defer { fixture.tearDown() }
        try fixture.freeStandingBoard(named: "a", title: "A", extraKeys: "template: {order: 100}")
        try fixture.freeStandingBoard(named: "b", title: "B", extraKeys: "template: {order: 4200}")

        #expect(TemplateEngine.nextUserOrder(in: fixture.store) == 4300)
    }
}

// MARK: - Collisions

@Suite("Save as Template — store collisions")
struct SaveAsTemplateCollisionTests {

    @Test("A name already in the store auto-renames, Finder-style — never overwritten, never refused")
    func collisionsAutoRename() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let first = try board.save()
        let second = try board.save()
        let third = try board.save()

        #expect(first.lastPathComponent == "Roadmap.kanban")
        #expect(second.lastPathComponent == "Roadmap 2.kanban")
        #expect(third.lastPathComponent == "Roadmap 3.kanban")
        #expect(try names(in: board.store) == ["Roadmap.kanban", "Roadmap 2.kanban", "Roadmap 3.kanban"])
    }

    @Test("The template already in the store is left exactly as it was")
    func theExistingTemplateIsUntouched() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        let first = try board.save()
        let before = try Data(contentsOf: first.appendingPathComponent("index.md"))

        try board.save()

        #expect(try Data(contentsOf: first.appendingPathComponent("index.md")) == before,
                "saving never overwrites an existing template and never refuses")
    }

    @Test("An extension-less board folder ladders on its whole name")
    func extensionlessNamesLadderToo() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let store = fixture.url("Templates")
        let root = fixture.url("Plain")
        try fixture.item("Plain", "---\nschema: 1\ntitle: Plain\n---\nBlurb.\n")

        let first = try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Plain", into: store)
        let second = try TemplateEngine.saveAsTemplate(boardAt: root, titled: "Plain", into: store)

        #expect(first.lastPathComponent == "Plain")
        #expect(second.lastPathComponent == "Plain 2")
    }
}

// MARK: - Nothing half-made

@Suite("Save as Template — the store entry is all or nothing")
struct SaveAsTemplateAtomicityTests {

    @Test("Cancelling mid-walk removes the partial store entry")
    func cancellingRemovesThePartial() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        // Trips on the fourth read: the entry exists by then and the walk is inside it, so there is
        // a genuine partial tree to remove.
        var reads = 0
        let failure = saveFailure {
            try board.save(isCancelled: {
                reads += 1
                return reads > 3
            })
        }

        #expect(failure == TemplateEngine.Failure.cancelled)
        #expect(try names(in: board.store).isEmpty, "a cancelled save never happened")
    }

    @Test("Cancelling before the first item leaves the store empty but minted")
    func cancellingBeforeTheWalkCreatesNoEntry() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }

        let failure = saveFailure { try board.save(isCancelled: { true }) }

        #expect(failure == TemplateEngine.Failure.cancelled)
        #expect(try names(in: board.store).isEmpty)
    }

    @Test("A board that is not there fails as a save, naming the operation, and leaves nothing behind")
    func aMissingBoardFailsCleanly() throws {
        let board = try SavableBoard()
        defer { board.tearDown() }
        try FileManager.default.removeItem(at: board.root)

        let failure = saveFailure { try board.save() }

        guard case let .failed(error) = failure else {
            Issue.record("expected an ordinary failure, got \(String(describing: failure))")
            return
        }
        #expect(error.operation == .saveAsTemplate(title: "Roadmap"))
        #expect(try names(in: board.store).isEmpty)
    }
}

// MARK: - The store's location

@Suite("Save as Template — the user store")
struct UserTemplateStoreTests {

    @Test("The user store is named beside the registry in the app's state home, and is never created by naming it")
    func theStoreIsNamedNotCreated() {
        let store = TemplateEngine.userStore

        #expect(store.lastPathComponent == TemplateEngine.storeFolderName)
        // Beside the registry and the clipboard's staging store (09-templates.md ▸ Storage;
        // 02-architecture.md § Per-board app state, "App-wide state has the same home"). Compared
        // against the one home rather than spelled out, so the assertion follows it wherever it goes —
        // including the scratch redirect a test host gets (`AppStateHome.isUnitTestHost`).
        // `AppStateHome` rather than the two stores' own defaults because those are `@MainActor` and
        // this suite is not; that the three agree is `AppStateHomeTests`' assertion.
        #expect(store.deletingLastPathComponent() == AppStateHome.directory)
        // Nothing here creates it, and no test may: `createUserStore(at:)` is Save as Template's and
        // Reveal in Finder's, and both are driven with an explicit store in this suite.
    }
}
