import Foundation
import SwiftGitX
import Testing
@testable import Kanban

/// **The board's git state** (06-history-undo.md ▸ Rules; 02-architecture.md ▸ Components
/// ▸ HistoryStore) — composed under the tier, detected at open, and changed afterwards by exactly
/// one thing.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2: the card's first criterion is that adding git "initializes a repo at the board root with
/// bundled libgit2 and no external git dependency", and a fixture faked out of hand-written files
/// could not tell whether that happened. Nothing in this file shells out to `git` — there is no
/// `/usr/bin/git` in the promise this feature makes, so there is none in its tests either.

// MARK: - Fixtures

/// A board with one lane and one card — small, and enough for a tree with three `index.md`s in it.
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
    return fixture
}

/// A `.git` that is not a repository — a directory with a plausible `HEAD` in it. Enough for
/// *detection*, which asks the filesystem one question, and deliberately not enough for libgit2,
/// which is how a test can tell the two apart.
private func plantGitDirectory(in fixture: WriterFixture, under parent: String = "") throws {
    let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"
    try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
}

/// A second (third, fourth) commit, made the way an external writer makes one — SwiftGitX directly,
/// not through the app, which has no commit surface until the auto-commit card.
private func commitEverything(at boardRoot: URL, message: String) throws {
    let repository = try Repository.open(at: boardRoot)
    try repository.add(paths: [])
    _ = try repository.commit(message: message)
}

/// Bytes and mtimes of everything under a subtree — `InertGitTests`' instrument, in the shape this
/// file needs it: what proves that *reading* a board's mode touched nothing.
private struct SubtreeEntry: Equatable {
    let path: String
    let data: Data?
    let modified: Date
}

private func snapshotGitDirectory(_ root: URL) throws -> [SubtreeEntry] {
    let base = root.appendingPathComponent(".git", isDirectory: true)
    let manager = FileManager.default
    guard let walker = manager.enumerator(atPath: base.path) else { return [] }

    var entries: [SubtreeEntry] = []
    for case let relative as String in walker {
        let url = base.appendingPathComponent(relative)
        let attributes = try manager.attributesOfItem(atPath: url.path)
        guard let modified = attributes[.modificationDate] as? Date else { continue }
        let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
        entries.append(SubtreeEntry(
            path: relative,
            data: isDirectory ? nil : try Data(contentsOf: url),
            modified: modified
        ))
    }
    return entries.sorted { $0.path < $1.path }
}

/// **A `.git` file aimed at nothing** — the worktree/submodule pointer shape (`gitdir: …`), which
/// detection reads as a repository (presence is presence, 06 ▸ Rules ▸ Detection) and libgit2 cannot
/// open, because the directory it names is not there.
private func plantDanglingGitPointer(in fixture: WriterFixture) throws {
    let target = fixture.root.appendingPathComponent("nowhere/.git/worktrees/board").path
    try fixture.file(".git", Data("gitdir: \(target)\n".utf8))
}

/// **A SHA-256 repository, by hand** — the layout libgit2 validates, plus the two config keys
/// `git init --object-format=sha256` writes (06 ▸ Repository hygiene: "SHA-256 repositories are
/// unsupported, safely … an adopted SHA-256 repo the engine cannot open takes the corrupt-repo
/// loud-failure path").
///
/// Built by hand rather than by `git init --object-format=sha256` for the file's standing reason:
/// there is no `/usr/bin/git` in this feature's promise, so there is none in its tests. What makes
/// the fixture honest is that nothing here is a mock — the bytes are the ones git writes, and the
/// refusal is libgit2's own.
private func plantSHA256Repository(in fixture: WriterFixture) throws {
    try fixture.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
    try fixture.file(".git/objects/info/.keep", Data())
    try fixture.file(".git/refs/heads/.keep", Data())
    try fixture.file(".git/config", Data("""
    [core]
    \trepositoryformatversion = 1
    \tbare = false
    [extensions]
    \tobjectformat = sha256

    """.utf8))
}

// MARK: - Composition

@MainActor
@Suite("HistoryStore ▸ composition and the tier gate")
struct HistoryStoreCompositionTests {

    @Test("The free tier composes no git state at all, on any board")
    func theFreeTierComposesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantGitDirectory(in: fixture)

        // Not "mode none on a git board" — *nothing*. With no object there is no path by which a
        // free-tier session could read history, commit, or touch `.git` (12-editions.md ▸ The free
        // tier and `.git`, whose byte-level half is `InertGitTests`).
        #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
    }

    @Test("Pro on a plain board is mode none — and opening one never creates a repository")
    func proOnAPlainBoardIsModeNone() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .none)

        // "No silent auto-init, ever" (06 ▸ Rules) — the deliberate pivot from the pathfinder, which
        // initialized a repository under every board it opened. Composing twice is the whole test:
        // two opens, no repository.
        _ = HistoryStore.compose(boardRoot: fixture.root, tier: .pro)
        #expect(!fixture.exists(".git"), "opening a mode-none board is not an opt-in")
    }

    @Test("A board whose root already has a repository opens in git mode, silently")
    func adoptionNeedsNoStep() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        // The board a second machine meets: someone ran `git init`/`git clone` (here, the app's own
        // add-git in a previous session), and the repository is simply there.
        let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await first.addGit())
        let afterInit = try #require(GitRepository.headCommit(at: fixture.root))
        let before = try snapshotGitDirectory(fixture.root)

        // The next open. Adoption is not init: no dialog, no confirmation, no second step — the mode
        // is simply what the filesystem says, and it says git.
        let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(second.mode == .git)

        // And nothing happened to the repository on the way in: same HEAD, same bytes, same mtimes.
        // Composition is a `stat`, not an operation.
        #expect(GitRepository.headCommit(at: fixture.root)?.oid == afterInit.oid)
        #expect(try snapshotGitDirectory(fixture.root) == before)
    }

    @Test("A board nested inside a repository opens repo-nested")
    func nestedBoardsAreDetectedAsNested() throws {
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        try plantGitDirectory(in: outer)

        let boardRoot = outer.root.appendingPathComponent("docs/board", isDirectory: true)
        try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
        try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))

        let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
        #expect(git.mode == .repoNested)
    }

    @Test("Mode is an open-time fact: a `.git` appearing mid-session does not flip the open board")
    func noMidSessionDiscoveredFlip() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .none)

        // What a `git init` in a terminal under an open board does — which is nothing, until the
        // next open (06 ▸ Rules: "the running session keeps its mode, and the watcher does not scan
        // for `.git` appearing").
        try plantGitDirectory(in: fixture)
        #expect(git.mode == .none, "the open session keeps the mode it composed with")

        let nextOpen = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(nextOpen.mode == .git, "and the next open reflects what it finds")
    }
}

// MARK: - The unreadable repository

/// **A `.git` that isn't a valid repository still reads as git mode — and fails loudly**
/// (06-history-undo.md ▸ Rules, ruled 2026-07-31).
///
/// The probe is `GitRepository.canOpen(at:)` — the same `Repository.open` every read in that file
/// makes — run at composition, seeding the committer's pause so the whole git surface is held from
/// the first moment rather than from the first debounce. Every fixture here is a real shape from the
/// wild: a half-made `.git`, a worktree pointer aimed at nothing, and a SHA-256 repository this
/// engine has no support for.
@MainActor
@Suite("HistoryStore ▸ the unreadable repository")
struct HistoryStoreUnreadableRepositoryTests {

    @Test("A repository that opens reads readable, and holds nothing")
    func aValidRepositoryIsReadable() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await first.addGit())

        // The next open, which is where the probe actually runs.
        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .git)
        #expect(!git.isRepositoryUnreadable)
        #expect(git.committer?.pause == nil)
        #expect(GitRepository.canOpen(at: fixture.root))
    }

    @Test("A corrupt `.git` stays git mode, reads unreadable, and holds the surface from the first moment")
    func aCorruptGitDirectoryIsUnreadable() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // A `.git` with nothing in it but a plausible HEAD: enough for detection, which asks the
        // filesystem one question, and not a repository at all to libgit2.
        try plantGitDirectory(in: fixture)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))

        // **Never a fall to mode none** — "detection is presence-shaped … a corrupt or unopenable
        // repo never falls to mode none", which is what keeps add-git from ever being offered
        // against an existing `.git` ("init into a repairable repo is exactly the never-mutate
        // hazard").
        #expect(git.mode == .git)
        #expect(git.isRepositoryUnreadable)
        #expect(!GitRepository.canOpen(at: fixture.root))

        // The pause is seeded at *detection*, before anything has been attempted: the surface is
        // held and the banner is raised at the open rather than a debounce later.
        #expect(git.committer?.pause == .unreadable)
        #expect(git.committer?.lastFailure == nil, "a pause is not a failure")

        // And the one operation that could make it worse is refused, whatever the mode read.
        #expect(await git.addGit() == false)
    }

    @Test("A worktree pointer aimed at nothing reads unreadable — the file shape, not just the directory one")
    func aDanglingPointerIsUnreadable() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantDanglingGitPointer(in: fixture)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .git, "a `.git` file is a repository to git — presence is presence")
        #expect(git.isRepositoryUnreadable)
    }

    @Test("A SHA-256 repository takes the same path, by construction")
    func aSHA256RepositoryIsUnreadable() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantSHA256Repository(in: fixture)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .git)
        // 06 ▸ Repository hygiene: "an adopted SHA-256 repo the engine cannot open takes the
        // corrupt-repo loud-failure path — never a silent fall to mode-none".
        #expect(git.isRepositoryUnreadable)
    }

    @Test("Probing an unreadable repository touches nothing")
    func theProbeIsARead() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantGitDirectory(in: fixture)
        let before = try snapshotGitDirectory(fixture.root)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.isRepositoryUnreadable)

        // "Lanework leaves the repository untouched" — the same bytes and the same mtimes, on the
        // one path where a repair instinct would be most tempting.
        #expect(try snapshotGitDirectory(fixture.root) == before)
    }

    @Test("The identity write is refused against a repository the app cannot open")
    func identityWritesAreRefused() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantGitDirectory(in: fixture)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        await git.writeIdentity(name: "Ada", email: "ada@example.com")

        #expect(!fixture.exists(".git/config"), "no config was written into a repository nothing can open")
        #expect(git.identityFailure == nil, "and nothing was attempted, so there is nothing to report")
    }

    /// The seam every git operation consults before it runs (`GitCommitOperation.reading`), asked
    /// directly: one word is what holds the auto-commit flush, skips housekeeping, disables Undo/Redo
    /// and the branch controls, and defers the interrupted-operation recovery.
    @Test("The repository reading reports the pause every operation gates on")
    func theReadingReportsThePause() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantGitDirectory(in: fixture)

        let reading = GitCommitOperation.reading(at: fixture.root)
        #expect(reading.pause == .unreadable)
        #expect(!reading.isUnborn)
        #expect(!reading.isIndexLocked)

        // Optional work simply does not happen (06 ▸ Repository hygiene: skipped under a pause).
        #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))

        // And the app never aborts its own leftover against a repository it cannot open — the stamp
        // is kept, not cleared, so the leftover stays recognizable as this app's.
        let stamp = GitOperationStamp(fromBranch: "main", toBranch: "redesign", headOID: nil)
        #expect(GitOperationRecovery.decide(stamp: stamp, pause: .unreadable) == .nothingToDo)
        #expect(GitOperationRecovery.decide(stamp: stamp, pause: .merge) == .abort(stamp),
                "every other pause still means the app's own leftover")
    }
}

// MARK: - Add git

@MainActor
@Suite("HistoryStore ▸ add-git")
struct HistoryStoreAddGitTests {

    @Test("Add-git initializes a repository at the board root and commits the whole tree")
    func addGitInitializesAndCommits() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await git.addGit())

        #expect(fixture.exists(".git"), "a repository at the board root — bundled libgit2, no git install")
        #expect(git.mode == .git, "the one commanded mid-session flip")

        let head = try #require(GitRepository.headCommit(at: fixture.root))
        // "The root commit has its own subject" (06 ▸ Rules ▸ Abnormal repo states) — never a folded
        // diff-from-empty, because there is nothing to diff against and forty Adds would bury it.
        #expect(head.subject == "Initial board state")
        #expect(head.parentCount == 0, "it is the root commit")

        // The whole tree, not a hand-picked set: the board, the lane, the card.
        let tracked = GitRepository.trackedPaths(at: fixture.root)
        #expect(tracked.contains("index.md"))
        #expect(tracked.contains("\(Ident.lane1)/index.md"))
        #expect(tracked.contains("\(Ident.lane1)/\(Ident.card1)/index.md"))
    }

    @Test("Add-git seeds a `.gitignore` into the initial commit")
    func addGitSeedsTheIgnoreFile() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await git.addGit())

        // The one file the app ever writes into a board because of git, and the one moment it writes
        // it (06 ▸ Repository hygiene). `RepositoryHygieneTests` carries the rest of the rule — the
        // untouched existing file, the `.DS_Store` that never enters history, adoption seeding
        // nothing; here it is only the fact that add-git's tree includes it.
        #expect(fixture.exists(".gitignore"))
        #expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
    }

    @Test("The root commit is authored by the derived default when the repo names nobody")
    func theRootCommitCarriesTheDerivedIdentity() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await git.addGit())

        let derived = GitIdentity.derivedDefault()
        let head = try #require(GitRepository.headCommit(at: fixture.root))
        #expect(head.authorName == derived.name)
        #expect(head.authorEmail == derived.email)
    }

    @Test("The branch is deterministic, and the popover's display line reads it")
    func theBranchIsMainAndReadable() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await git.addGit())

        // Deterministic rather than inherited: libgit2's initial branch comes from an
        // `init.defaultBranch` this app cannot read in the sandbox (`GitRepository.initialBranchName`).
        #expect(git.branch == "main")
        #expect(GitRepository.branchName(at: fixture.root) == "main")

        await git.refreshBranch()
        #expect(git.branch == "main")
    }

    @Test("An unborn HEAD still has a branch name — a repository with no commits is normal git mode")
    func anUnbornHeadIsNormal() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        // `git init` and nothing else: the shape an adopted repository can genuinely be in
        // (06 ▸ Rules ▸ Abnormal repo states: "an unborn HEAD is normal git mode").
        _ = try Repository.create(at: fixture.root)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .git)
        #expect(GitRepository.branchName(at: fixture.root) != nil)
        #expect(GitRepository.headCommit(at: fixture.root) == nil, "no commits yet, and that is fine")
    }

    @Test("Add-git refuses a board that already has a repository, and changes nothing")
    func addGitRefusesAnAdoptedBoard() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let first = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await first.addGit())
        let head = try #require(GitRepository.headCommit(at: fixture.root))

        let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await second.addGit() == false, "there is nothing to add")
        #expect(GitRepository.headCommit(at: fixture.root)?.oid == head.oid, "and nothing was re-initialized")
    }

    @Test("Add-git refuses a repo-nested board — no nested repository, ever")
    func addGitRefusesANestedBoard() async throws {
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        try plantGitDirectory(in: outer)

        let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
        try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
        try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))

        let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
        #expect(await git.addGit() == false)
        #expect(git.mode == .repoNested)
        #expect(!FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path))
    }

    @Test("A refused add-git says why, in libgit2's words where it has any")
    func aRefusalIsExplained() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantGitDirectory(in: fixture)

        // Mode `git` is refused by `HistoryStore` before libgit2 is reached, so the failure the
        // popover would show comes from the layer that *would* have run it.
        let failure = GitRepository.create(at: fixture.root)
        guard case .failure(let reason) = failure else {
            Issue.record("initializing over an existing repository must be refused")
            return
        }
        #expect(reason.operation == "Adding git to this board")
        #expect(!reason.message.isEmpty)
    }

    @Test("Create re-runs full detection and refuses a board that became repo-nested")
    func createRefusesAStaleModeNone() async throws {
        // **The hardening** (06 ▸ Rules ▸ Detection, ruled 2026-07-31): "add-git's create re-runs full
        // detection and refuses unless it reads clean none, so the forbidden nested init is impossible
        // even on a raced or stale read."
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        let boardRoot = outer.root.appendingPathComponent("board", isDirectory: true)
        try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
        try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))

        // Composed while the enclosing folder is still a plain one: the store's mode is `none`, and
        // that is the reading that goes stale.
        let git = try #require(HistoryStore.compose(boardRoot: boardRoot, tier: .pro))
        #expect(git.mode == .none)

        // A terminal `git init` one level up, after the detection the store is holding.
        try plantGitDirectory(in: outer)

        #expect(await git.addGit() == false, "a root-only check would have let this through")
        #expect(
            !FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
            "no nested repository, ever"
        )
        #expect(git.mode == .none, "a refused add-git changes nothing, mode included")
    }

    @Test("Create refuses a board that a fresh detection reads unverifiable — a denied ancestor")
    func createRefusesUnverifiable() throws {
        // **The tightened guard** (06 ▸ Rules ▸ Detection): "only a genuinely clean `.none` reading
        // proceeds" — a stale `.none` that has since become unverifiable is refused exactly like one
        // that has since become repo-nested (`createRefusesAStaleModeNone` above).
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        let blocked = outer.root.appendingPathComponent("blocked", isDirectory: true)
        let boardRoot = blocked.appendingPathComponent("board", isDirectory: true)
        try FileManager.default.createDirectory(at: boardRoot, withIntermediateDirectories: true)
        try Data(Item.board.utf8).write(to: boardRoot.appendingPathComponent("index.md"))

        try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: blocked.path)
        defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: blocked.path) }

        #expect(BoardGitMode.detect(boardRoot: boardRoot) == .unverifiable, "the fixture is set up correctly")

        let failure = GitRepository.create(at: boardRoot)
        guard case .failure(let reason) = failure else {
            Issue.record("initializing where detection cannot rule out a repository must be refused")
            return
        }
        #expect(reason.operation == "Adding git to this board")
        #expect(!reason.message.isEmpty)
        #expect(
            !FileManager.default.fileExists(atPath: boardRoot.appendingPathComponent(".git").path),
            "no repository created on an unverifiable read"
        )
    }

    @Test("A failure answers at the form when it is up, and at the banner when it is not")
    @MainActor
    func aFailureAnswersAtTheFormOrTheBanner() async throws {
        // **Form-anchored operations answer at the form first** (06 ▸ Interaction with external
        // writers, ruled 2026-07-31) — "inline is the primary surface, never a silence trap".
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        // Mode is read once, at composition — so a store composed before a `.git` appeared still says
        // `none` and reaches `create`, which is the layer that refuses. Any refusal will do here; the
        // question is where the answer lands.
        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        try plantGitDirectory(in: fixture)
        var banners: [String] = []
        git.reportFailure = { banners.append($0.message) }

        // The form is up: inline, and nothing on the strip.
        git.noteFormVisible(true)
        #expect(await git.addGit() == false)
        #expect(git.lastFailure != nil)
        #expect(banners.isEmpty, "the user is looking at the form the answer belongs in")

        // Dismissing it dismisses the stale error.
        git.noteFormVisible(false)
        #expect(git.lastFailure == nil)

        // Asked again with no form on screen, the answer takes the banner instead of nobody.
        #expect(await git.addGit() == false)
        #expect(git.lastFailure == nil)
        #expect(banners.count == 1)
    }
}

// MARK: - The loader's history ranker

@MainActor
@Suite("HistoryStore ▸ the git-backed identity ranker")
struct GitPathHistoryTests {

    @Test("A path that entered history earlier ranks lower; an untracked one has no rank")
    func ranksFollowHistory() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await git.addGit())

        // A second card, arriving in a later commit — the whole point of the rung.
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
        try commitEverything(at: fixture.root, message: "Add card 'Second'")

        // A third, on disk but never committed.
        try fixture.item("\(Ident.lane1)/\(Ident.card3)", Item.rich(order: "3072", title: "Third"))

        let ranker = try #require(git.identityHistoryRanker)
        let first = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card1)"))
        let second = try #require(ranker.rank("\(Ident.lane1)/\(Ident.card2)"))

        #expect(first < second, "lower is earlier")
        #expect(ranker.rank("\(Ident.lane1)/\(Ident.card3)") == nil, "untracked is no rank at all")
        #expect(ranker.rank(Ident.lane1) == first, "a folder ranks with the first file that landed in it")
    }

    @Test("No ranker where the app manages no git")
    func noRankerWithoutARepository() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(plain.identityHistoryRanker == nil, "mode none injects nothing")

        // And the free tier has no `HistoryStore` to ask in the first place — pinned in the
        // composition suite above.
    }

    @Test("Git history decides a real duplicate-id collision through the loader")
    func historyDecidesTheDuplicateWinner() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await git.addGit())

        // The same identity, arriving later in a second lane — the copy the duplicate rule is about.
        try fixture.item("\(Ident.lane2)/\(Ident.card1)", Item.rich(order: "1024", title: "First (copy)"))
        try commitEverything(at: fixture.root, message: "Copy the card")

        let result = try BoardLoader.load(boardRoot: fixture.root, historyRanker: git.identityHistoryRanker)
        let duplicates: [DuplicateIdentity] = result.defects.compactMap {
            if case .duplicateIdentity(let duplicate) = $0 { return duplicate }
            return nil
        }

        let duplicate = try #require(duplicates.first)
        #expect(duplicate.winner == "\(Ident.lane1)/\(Ident.card1)", "the path that entered history first")
        #expect(duplicate.path == "\(Ident.lane2)/\(Ident.card1)", "the newcomer is the one withheld")
    }
}

// MARK: - Session composition

@MainActor
private func makeModel() throws -> (model: AppModel, tearDown: () -> Void) {
    let folder = FileManager.default.temporaryDirectory
        .appendingPathComponent("HistoryStoreTests-\(UUID().uuidString)", isDirectory: true)
    try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
    let model = AppModel(
        registryStorageURL: folder.appendingPathComponent("board-registry.json"),
        clipboardStagingRoot: folder.appendingPathComponent("Clipboard", isDirectory: true)
    )
    return (model, { try? FileManager.default.removeItem(at: folder) })
}

@MainActor
@discardableResult
private func openBoard(_ model: AppModel, at url: URL) throws -> BoardWindowRef {
    let ref = BoardWindowRef(url: url)
    let recordID = model.boardRegistry.recordOpen(of: url)
    let store = try model.storeRegistry.acquire(url)
    model.boardRegistry.setOpenNow(id: recordID)
    model.beginSession(ref: ref, store: store, recordID: recordID, access: nil)
    return ref
}

@MainActor
@Suite("Board sessions ▸ the git state they compose")
struct BoardSessionGitTests {

    @Test("A free-tier session carries no git state, even on a board that has a repository")
    func freeSessionsCarryNoGitState() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try plantGitDirectory(in: fixture)
        let (model, tearDown) = try makeModel()
        defer { tearDown() }

        model.currentTier = { .free }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        #expect(session.git == nil)
        #expect(session.gitMode == .none, "the free tier ships exactly one mode")
        #expect(session.store.makeIdentityHistoryRanker == nil, "and injects nothing into the loader")
    }

    @Test("A Pro session on a git board composes git mode and wires the loader's ranker")
    func proSessionsCarryTheDetectedMode() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }

        // A real repository, so the ranker has something to read.
        let seed = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(await seed.addGit())

        model.currentTier = { .pro }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        #expect(session.gitMode == .git)
        let provider = try #require(session.store.makeIdentityHistoryRanker)
        let ranker = try #require(provider())
        #expect(ranker.rank("\(Ident.lane1)/\(Ident.card1)") != nil)

        // The provider binding arrived with the undo/redo card: a Pro session on a git board binds
        // the git substrate over exactly this mode (12-editions.md ▸ The provider seam).
        #expect(session.history is GitHistoryProvider)
    }

    @Test("A Pro session on a plain board is mode none and injects nothing")
    func proSessionsOnPlainBoardsInjectNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let (model, tearDown) = try makeModel()
        defer { tearDown() }

        model.currentTier = { .pro }
        let ref = try openBoard(model, at: fixture.root)
        let session = try #require(model.session(for: ref))

        #expect(session.gitMode == .none)
        let provider = try #require(session.store.makeIdentityHistoryRanker, "the wiring is there")
        #expect(provider() == nil, "and it answers nothing on a board with no repository")
    }
}
