import Foundation
import Testing
@testable import Kanban

/// File ▸ Duplicate's copy (03-board-ui.md § Welcome screen & templates).
///
/// Three promises, and all three are about what the copy *doesn't* do:
///
/// - it doesn't remint — "The copy keeps every GUID", 01-storage-format.md's whole-board carve-out,
///   and the thing that keeps a copied `.git` history naming paths that still exist;
/// - it doesn't drop the trash — "Tombstoned items are carried too (settled) ... the duplicate is
///   born exactly matching its history";
/// - it doesn't overwrite — the Finder-style `copy` ladder renames instead.
///
/// And a fourth, which the per-item walk exists for: **it doesn't leave residue** — a cancelled or
/// failed copy removes its own partial sibling, "a cancelled duplicate never happened".
///
/// Tested against real folders, because every one of those is a claim about bytes on disk. The
/// window flow around the copy (the flush, the banner row, the save panel, the open that follows) is
/// the command's, not this type's — what belongs here is the vocabulary that flow branches on:
/// cancelled, refused, failed.

// MARK: - Fixtures

/// A board with a live lane, a tombstoned lane, a live card, a tombstoned card, and a stray file —
/// everything a literal copy has to carry through untouched.
@MainActor
private func makeBoard(named name: String) throws -> (fixture: WriterFixture, root: URL) {
    let fixture = try WriterFixture()
    let root = fixture.url(name)
    try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true)

    try fixture.item(name, Item.board)
    try fixture.item("\(name)/\(Ident.lane1)", Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(name)/\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
    try fixture.item(
        "\(name)/\(Ident.lane1)/\(Ident.card2)",
        "---\nschema: 1\norder: 2048\ntitle: Gone\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
    )
    try fixture.item(
        "\(name)/\(Ident.lane2)",
        "---\nschema: 1\norder: 2048\ntitle: Archive\ndeleted: 2026-01-01T00:00:00Z\n---\nbody\n"
    )
    try fixture.item("\(name)/\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Buried"))
    try fixture.file("\(name)/CLAUDE.user.md", Data("board instructions\n".utf8))

    return (fixture, root)
}

/// Every path under `root`, board-root-relative, hidden entries included — what "a literal copy"
/// means as an assertion.
private func tree(of root: URL) throws -> 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
}

/// `writeFailure`'s twin for the duplicate's own three-outcome vocabulary: the copy answers
/// `.cancelled`, `.refused` or `.failed`, and which one is the whole question every test below asks.
private func duplicateFailure(_ operation: () throws -> Void) -> BoardDuplicator.Failure? {
    do {
        try operation()
        Issue.record("expected the duplicate to fail, but it succeeded")
        return nil
    } catch let failure as BoardDuplicator.Failure {
        return failure
    } catch {
        Issue.record("expected a BoardDuplicator.Failure, got \(error)")
        return nil
    }
}

// MARK: - Tests

@MainActor
@Suite("BoardDuplicator")
struct BoardDuplicatorTests {

    // MARK: The name ladder

    @Test("The first duplicate is a 'copy' sibling, extension carried")
    func firstCopyIsNamedFinderStyle() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }

        let destination = BoardDuplicator.copyDestination(for: board.root)

        #expect(destination.lastPathComponent == "Roadmap copy.kanban")
        #expect(destination.deletingLastPathComponent().path == board.root.deletingLastPathComponent().path,
                "a sibling — where the user is already looking")
    }

    @Test("An extension-less board has none to carry")
    func extensionlessBoardCopiesWithoutOne() throws {
        let board = try makeBoard(named: "Plain")
        defer { board.fixture.tearDown() }

        #expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Plain copy")
    }

    @Test("The collision ladder counts up from 2, one collision at a time")
    func collisionLadderCountsUp() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }

        let first = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
        #expect(first.lastPathComponent == "Roadmap copy.kanban")

        let second = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
        #expect(second.lastPathComponent == "Roadmap copy 2.kanban")

        let third = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
        #expect(third.lastPathComponent == "Roadmap copy 3.kanban")

        // And duplicating a duplicate ladders off *its* name, exactly as Finder does.
        let ofACopy = try BoardDuplicator.duplicate(boardAt: first, titled: "Roadmap copy")
        #expect(ofACopy.lastPathComponent == "Roadmap copy copy.kanban")
    }

    @Test("Anything already wearing the name blocks it, folder or file alike")
    func anyExistingEntryBlocksTheName() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        // Not a board — just a file in the way. A duplicate must rename around it, never through it.
        try board.fixture.file("Roadmap copy.kanban", Data("in the way".utf8))

        #expect(BoardDuplicator.copyDestination(for: board.root).lastPathComponent == "Roadmap copy 2.kanban")
    }

    // MARK: What lands in the copy

    @Test("Every GUID is kept — the whole-board carve-out from the remint rule")
    func guidsArePreserved() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }

        let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")

        #expect(try tree(of: copy) == tree(of: board.root),
                "same identities, same nesting — a copied history keeps naming paths that exist")
        #expect(FileManager.default.fileExists(atPath: copy.appendingPathComponent(Ident.lane1).path))
        #expect(FileManager.default.fileExists(
            atPath: copy.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)").path
        ))
    }

    @Test("Tombstoned lanes and cards are carried, trash included")
    func tombstonesAreCarried() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }

        let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")
        let loaded = try BoardLoader.load(boardRoot: copy).model

        let archive = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane2 })
        #expect(archive.isDeleted, "the tombstoned lane came along — Duplicate is a full fork")
        #expect(archive.cards.contains { $0.id.rawValue == Ident.card3 }, "and everything beneath it")

        let todo = try #require(loaded.lanes.first { $0.id.rawValue == Ident.lane1 })
        #expect(todo.cards.contains { $0.id.rawValue == Ident.card2 && $0.isDeleted })
    }

    @Test("The copy is byte-for-byte, strays and stale attribution included")
    func contentIsCopiedVerbatim() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }

        let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")

        func bytes(_ root: URL, _ relative: String) throws -> Data {
            try Data(contentsOf: root.appendingPathComponent(relative))
        }
        #expect(try bytes(copy, "index.md") == bytes(board.root, "index.md"))
        #expect(try bytes(copy, "\(Ident.lane1)/\(Ident.card1)/index.md")
                    == bytes(board.root, "\(Ident.lane1)/\(Ident.card1)/index.md"),
                "created, unknown keys and modified-by all survive — nothing here reads a board file")
        #expect(try bytes(copy, "CLAUDE.user.md") == bytes(board.root, "CLAUDE.user.md"))
    }

    @Test("The original is untouched by its own duplication")
    func originalSurvivesUnchanged() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        let before = try tree(of: board.root)

        _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")

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

    // MARK: Failure

    @Test("A source that isn't there fails as a duplicate, naming the board")
    func missingSourceFailsInTheDuplicateVocabulary() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        let missing = board.fixture.url("Never.kanban")

        let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: missing, titled: "Never") }

        // An ordinary failure, not a refusal: a source that isn't there is not a question about
        // where the copy should go, so no save panel could help it.
        guard case let .failed(error) = failure else {
            Issue.record("expected an ordinary failure, got \(String(describing: failure))")
            return
        }
        #expect(error.operation == .duplicateBoard(title: "Never"),
                "the user pressed Duplicate; the banner must say so")
        #expect(BannerCenter.headline(for: error).hasPrefix("Couldn't duplicate 'Never'"))
    }

    // MARK: - The per-item walk

    @Test("Hidden folders, empty ones and nested strays all ride along — the walk is not the loader")
    func theWalkCarriesEverythingATreeCanHold() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        // A `.git`-shaped hidden folder with a file in it, an empty folder inside that (the shape a
        // fresh repository actually has), and a stray beside a card's index.
        try board.fixture.file("Roadmap.kanban/.git/HEAD", Data("ref: refs/heads/main\n".utf8))
        try FileManager.default.createDirectory(
            at: board.root.appendingPathComponent(".git/objects", isDirectory: true),
            withIntermediateDirectories: true
        )
        try board.fixture.file("Roadmap.kanban/\(Ident.lane1)/\(Ident.card1)/attachments/shot.png", Data([0x01, 0x02]))

        let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap")

        #expect(try tree(of: copy) == tree(of: board.root),
                "an item-by-item walk carries exactly what one copyItem would have")
        #expect(try Data(contentsOf: copy.appendingPathComponent(".git/HEAD"))
                    == Data("ref: refs/heads/main\n".utf8))
        var isDirectory: ObjCBool = false
        #expect(FileManager.default.fileExists(
            atPath: copy.appendingPathComponent(".git/objects").path,
            isDirectory: &isDirectory
        ) && isDirectory.boolValue, "an empty folder is a folder, not nothing")
    }

    // MARK: - Cancel

    @Test("Cancelling between items removes the partial sibling — a cancelled duplicate never happened")
    func cancellingRemovesThePartialSibling() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        let before = try tree(of: board.root)

        // Trips on the fourth read: the root folder exists by then and the walk is two levels deep
        // into it, so there is a genuine partial tree to remove rather than nothing to clean up.
        var reads = 0
        let failure = duplicateFailure {
            _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: {
                reads += 1
                return reads > 3
            })
        }

        #expect(failure == BoardDuplicator.Failure.cancelled)
        #expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"],
                "no 'Roadmap copy.kanban' survives — the partial went with the cancellation")
        #expect(try tree(of: board.root) == before, "and the original was never touched")
    }

    @Test("A duplicate cancelled before it began never creates the destination at all")
    func cancellingBeforeTheFirstItemCreatesNothing() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }

        let failure = duplicateFailure {
            _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", isCancelled: { true })
        }

        #expect(failure == BoardDuplicator.Failure.cancelled)
        #expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"])
    }

    @Test("A failure mid-walk removes the partial sibling too, and says what went wrong")
    func aFailedWalkRemovesThePartialSibling() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        // A card folder the walk cannot list, standing in for any I/O that gives out part-way.
        let unreadable = board.root.appendingPathComponent("\(Ident.lane1)/\(Ident.card1)")
        try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: unreadable.path)
        defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: unreadable.path) }

        let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") }

        guard case let .failed(error) = failure else {
            Issue.record("expected an ordinary failure, got \(String(describing: failure))")
            return
        }
        #expect(error.operation == .duplicateBoard(title: "Roadmap"))
        #expect(error.path == unreadable.path, "fail-fast names the item it stopped on")
        #expect(try Set(board.fixture.entryNames("")) == ["Roadmap.kanban"],
                "a half-copied board is residue — it goes, and the banner is what remains")
    }

    // MARK: - Refusal and the panel's answer

    @Test("A sandbox refusal of the sibling is a refusal, not a failure — the save panel's cue")
    func anUnwritableParentIsARefusal() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        // The board's *parent* is unwritable while the board itself is not: exactly the shape of a
        // bookmark that grants a subtree and not the folder above it.
        try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path)
        defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) }

        let failure = duplicateFailure { _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap") }

        guard case let .refused(error) = failure else {
            Issue.record("expected a refusal, got \(String(describing: failure))")
            return
        }
        #expect(error.operation == .duplicateBoard(title: "Roadmap"))
    }

    @Test("A refusal at a destination the user chose is an ordinary failure — the panel isn't asked twice")
    func aRefusalAtAnAnsweredDestinationIsAnOrdinaryFailure() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: board.fixture.root.path)
        defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: board.fixture.root.path) }

        let chosen = board.fixture.url("Chosen.kanban")
        let failure = duplicateFailure {
            _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen)
        }

        guard case .failed = failure else {
            Issue.record("expected an ordinary failure, got \(String(describing: failure))")
            return
        }
    }

    @Test("A chosen destination is honored verbatim, name and location alike")
    func aChosenDestinationIsHonoredVerbatim() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        let elsewhere = board.fixture.url("Elsewhere")
        try FileManager.default.createDirectory(at: elsewhere, withIntermediateDirectories: true)
        // Not the "copy" ladder's answer at all: another folder, another name, no extension.
        let chosen = elsewhere.appendingPathComponent("Fork", isDirectory: true)

        let copy = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: chosen)

        #expect(copy == chosen)
        #expect(try tree(of: copy) == tree(of: board.root))
    }

    @Test("A chosen name already on disk fails, and is never overwritten or removed")
    func aChosenNameAlreadyTakenFails() throws {
        let board = try makeBoard(named: "Roadmap.kanban")
        defer { board.fixture.tearDown() }
        let occupied = try board.fixture.file("Taken.kanban", Data("not a board, and not yours to delete".utf8))

        let failure = duplicateFailure {
            _ = try BoardDuplicator.duplicate(boardAt: board.root, titled: "Roadmap", into: occupied)
        }

        guard case .failed = failure else {
            Issue.record("expected an ordinary failure, got \(String(describing: failure))")
            return
        }
        #expect(try board.fixture.data("Taken.kanban") == Data("not a board, and not yours to delete".utf8),
                "the destination this call did not create is the one thing cleanup never removes")
    }

    // MARK: - Classifying a refusal

    @Test("Only a write-permission denial is the panel's question; everything else is a banner")
    func permissionRefusalsAreClassifiedNarrowly() {
        #expect(BoardDuplicator.isPermissionRefusal(
            NSError(domain: NSCocoaErrorDomain, code: NSFileWriteNoPermissionError)
        ))
        #expect(BoardDuplicator.isPermissionRefusal(
            NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))
        ))
        #expect(BoardDuplicator.isPermissionRefusal(NSError(
            domain: "SomeWrapper",
            code: 1,
            userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EPERM))]
        )), "an unclassified wrapper is followed through to the refusal underneath")

        #expect(!BoardDuplicator.isPermissionRefusal(
            NSError(domain: NSCocoaErrorDomain, code: NSFileWriteOutOfSpaceError)
        ), "a full disk is a real failure — a save panel would be the app blaming the user")
        #expect(!BoardDuplicator.isPermissionRefusal(
            NSError(domain: NSCocoaErrorDomain, code: NSFileWriteVolumeReadOnlyError)
        ))
        #expect(!BoardDuplicator.isPermissionRefusal(NSError(
            domain: NSCocoaErrorDomain,
            code: NSFileReadNoPermissionError,
            userInfo: [NSUnderlyingErrorKey: NSError(domain: NSPOSIXErrorDomain, code: Int(EACCES))]
        )), """
        a source we cannot read is a broken board, not a question about where the copy goes — and \
        Foundation hangs the same EACCES under it, which is why the chain is not searched blindly
        """)
    }
}
