import Foundation
import SwiftGitX
import Testing
import libgit2
@testable import Kanban

/// **Repository hygiene** (06-history-undo.md ▸ Repository hygiene) — the behaviours that keep a
/// board's noise out of the way without ever rewriting anything: the `.gitignore` **every board**
/// carries (re-ruled 2026-07-31 — the file outgrew git, so it is seeded at creation and healed in at
/// open, git or not), and the periodic repack that packs loose objects and touches nothing else.
///
/// Every repository here is a **real** one, made by the app's own add-git through the bundled
/// libgit2, and every assertion is read off the filesystem or out of the object database rather than
/// through a mock: a housekeeping bug corrupts repositories, so the only tests worth having are the
/// ones a corrupt repository would fail.
///
/// Nothing here shells out to `git`.

// MARK: - Fixtures

/// A board with one lane and one card — 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 commit made the way an external writer makes one — SwiftGitX directly, so the objects under
/// test are ordinary git objects and not something the app's own path produced.
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)
}

/// Enough commits that the repository has a non-trivial pile of loose objects to pack.
private func churn(_ fixture: WriterFixture, commits: Int) throws {
    for step in 1...commits {
        try fixture.item(
            "\(Ident.lane1)/\(Ident.card2)",
            Item.rich(order: "\(step)024", title: "Second, take \(step)")
        )
        try fixture.file("notes.txt", Data(String(repeating: "\(step)", count: 64).utf8))
        try commitEverything(at: fixture.root, message: "Change \(step)")
    }
}

// MARK: - Filesystem instruments

/// One entry under a subtree: path, bytes (nil for directories), and mtime — `InertGitTests`'
/// instrument. Bytes alone would pass a rewrite with identical content; the mtime is the assertion
/// that nothing opened the file for writing at all.
private struct SubtreeEntry: Equatable, CustomStringConvertible {
    let path: String
    let data: Data?
    let modified: Date

    var description: String {
        "\(path) (\(data.map { "\($0.count) bytes" } ?? "directory"), modified \(modified))"
    }
}

/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `skip` prunes
/// whole branches — how the working tree is snapshotted without `.git`, and `.git` without
/// `objects/`.
private func snapshot(
    _ root: URL,
    _ subtree: String,
    skipping skip: Set<String> = []
) throws -> [SubtreeEntry] {
    let base = subtree.isEmpty ? root : root.appendingPathComponent(subtree, 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 head = relative.split(separator: "/").first.map(String.init) ?? relative
        if skip.contains(head) {
            walker.skipDescendants()
            continue
        }
        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 }
}

// MARK: - Object-database instruments

/// **Every object the repository can answer for**, loose and packed alike, by oid.
///
/// This is the "nothing was forgotten" instrument, and `git_odb_foreach` is the only honest way to
/// ask it: it enumerates the whole database through every backend, so a repack that packed some
/// objects and dropped others shows up as a set that shrank. Read through a repository opened
/// *after* the pass, so the answer comes from what is on disk rather than from a cached view of what
/// used to be.
private func everyObject(at boardRoot: URL) -> Set<String> {
    var repository: OpaquePointer?
    guard git_repository_open(&repository, boardRoot.path) == 0, let repository else { return [] }
    defer { git_repository_free(repository) }

    var database: OpaquePointer?
    guard git_repository_odb(&database, repository) == 0, let database else { return [] }
    defer { git_odb_free(database) }

    var found = Set<String>()
    withUnsafeMutablePointer(to: &found) { payload in
        _ = git_odb_foreach(database, { oid, payload in
            guard let oid, let payload else { return 0 }
            var value = oid.pointee
            var buffer = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
            git_oid_fmt(&buffer, &value)
            payload.assumingMemoryBound(to: Set<String>.self).pointee.insert(String(cString: buffer))
            return 0
        }, payload)
    }
    return found
}

/// The oid git would give a file's bytes as a blob — `git hash-object`, computed rather than looked
/// up, so a test can ask "is *this content* still in the database" without walking a tree to find it.
private func blobOID(of data: Data) -> String? {
    var oid = git_oid()
    let status = data.withUnsafeBytes { buffer in
        git_odb_hash(&oid, buffer.baseAddress, buffer.count, GIT_OBJECT_BLOB)
    }
    guard status == 0 else { return nil }
    var value = oid
    var text = [CChar](repeating: 0, count: Int(GIT_OID_MAX_HEXSIZE) + 1)
    git_oid_fmt(&text, &value)
    return String(cString: text)
}

/// HEAD's first-parent ancestry, oldest last — the history walk, as oids and subjects, so "identical"
/// means identical rather than "HEAD still resolves".
private func historyWalk(at boardRoot: URL) throws -> [String] {
    let repository = try Repository.open(at: boardRoot)
    guard var commit = try repository.HEAD.target as? Commit else { return [] }
    var trail = ["\(commit.id.hex) \(commit.summary)"]
    while let parent = try commit.parents.first {
        commit = parent
        trail.append("\(commit.id.hex) \(commit.summary)")
    }
    return trail
}

// MARK: - .gitignore seeding

/// **The add-git half.** Since 2026-07-31 the seed belongs to the *board* rather than to git (the
/// suite below this one), and what survives here is the last-chance check in front of the initial
/// commit: whatever else happened, the tree that becomes "Initial board state" carries a
/// `.gitignore`, because a `.DS_Store` that enters history can never be got out again (06 ▸ Deleting
/// never forgets).
@MainActor
@Suite("Repository hygiene ▸ the seeded .gitignore")
struct GitignoreSeedTests {

    @Test("Add-git guarantees a .gitignore inside 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 file, and the whole of the file — the one seed text, shared with board creation and
        // the open-time heal (06 ▸ Repository hygiene: "`.DS_Store` plus the writer's temp pattern").
        #expect(try fixture.data(".gitignore") == Data(BoardWriter.gitignoreSeed.utf8))

        // **In "Initial board state", not after it.** Seeding after the commit would put the app's
        // own file into the board's first *foreign* commit; seeding before makes it part of the
        // board's beginning, which is what it is.
        let head = try #require(GitRepository.headCommit(at: fixture.root))
        #expect(head.subject == "Initial board state")
        #expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
    }

    @Test("A .DS_Store already under the board never enters history at all")
    func theSeedTakesEffectFromTheFirstCommit() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // What the Finder leaves behind: one per folder the user has looked at.
        try fixture.file(".DS_Store", Data([0x00, 0x01, 0x42]))
        try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x43]))

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

        // Not "removed from history later" — never in it. The app has no history-rewriting operation
        // and never will (06 ▸ Deleting never forgets), so the only moment this could be got right
        // is the first one.
        let tracked = GitRepository.trackedPaths(at: fixture.root)
        #expect(!tracked.contains { $0.hasSuffix(".DS_Store") })
        #expect(fixture.exists(".DS_Store"), "and the file itself is left exactly where it is")
    }

    @Test("A board that already has a .gitignore is left byte-for-byte alone")
    func anExistingIgnoreFileIsUntouched() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let mine = Data("# mine\nbuild/\n*.tmp\n".utf8)
        try fixture.file(".gitignore", mine)
        let before = try snapshot(fixture.root, ".gitignore")

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

        // Not merged, not appended to, not reordered — and not even opened for writing, which is
        // what the mtime says (06: "the app never edits an existing one").
        #expect(try fixture.data(".gitignore") == mine)
        #expect(try snapshot(fixture.root, ".gitignore") == before)
        #expect(GitRepository.trackedPaths(at: fixture.root).contains(".gitignore"))
    }

    @Test("The app never manages the file afterwards — commits and housekeeping leave it alone")
    func theFileIsTheUsersFromThenOn() 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 user edits it — including deleting the line the app seeded, which is their business.
        let theirs = Data("*.log\n".utf8)
        try fixture.file(".gitignore", theirs)
        let before = try snapshot(fixture.root, ".gitignore")

        try churn(fixture, commits: 3)
        _ = GitHousekeeping.run(at: fixture.root, threshold: 1)
        await git.committer?.flushNow()

        #expect(try fixture.data(".gitignore") == theirs, "nothing in the app re-seeds it")
        #expect(try snapshot(fixture.root, ".gitignore") == before)
    }

    /// **The second consumer of the one noise definition** (01-storage-format.md § Fractal layout ▸
    /// Rules: "On Pro boards the same file governs the committer, so ignored noise neither relocates
    /// nor commits — one definition of noise, two consumers"). The committer's own condition is
    /// `changedPaths`, which stages through libgit2 with ignores respected; this pins that the file
    /// the loose-file gate reads is the file that decides what commits.
    @Test("The committer obeys the same file — ignored noise never becomes a changed path")
    func theCommitterObeysTheSameFile() 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 user fine-tunes their own noise definition, which is exactly what the file is for.
        try fixture.file(".gitignore", Data((BoardWriter.gitignoreSeed + "*.tmp\n").utf8))
        try fixture.file("\(Ident.lane1)/\(Ident.card1)/scratch.tmp", Data("noise".utf8))
        try fixture.file("\(Ident.lane1)/notes.txt", Data("a real stray".utf8))
        try fixture.file("\(Ident.lane1)/.DS_Store", Data([0x00, 0x01, 0x42]))

        let changed = GitCommitOperation.changedPaths(at: fixture.root).map(\.path)
        #expect(!changed.contains { $0.hasSuffix("scratch.tmp") })
        #expect(!changed.contains { $0.hasSuffix(".DS_Store") })
        #expect(changed.contains { $0.hasSuffix("notes.txt") }, "and an ordinary stray still commits")
    }

    /// Composing history over somebody else's repository writes nothing at all — adoption is not an
    /// init, and no *git* path seeds. (The board's own heal is what gives such a board its
    /// `.gitignore`, at open, and it is exercised in the suite below.)
    @Test("Adoption writes nothing — an adopted repository is somebody else's init")
    func adoptionSeedsNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // `git init` run outside the app, exactly the shape a cloned or hand-inited board arrives in.
        _ = try Repository.create(at: fixture.root)

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(git.mode == .git)
        #expect(!fixture.exists(".gitignore"), "composing history is not a write")
    }

    /// A repo-nested board gets no *git* of the app's, so no git path can seed it — and the
    /// enclosing repository is never written into either. What such a board does get is the ordinary
    /// board-level seed at open (06's "Repo-nested boards are seeded too"), which is the suite below.
    @Test("The git paths never touch a repo-nested board, or its enclosing repo")
    func repoNestedBoardsGetNothingFromGit() async throws {
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))

        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(".gitignore").path))
        #expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(".gitignore").path))
    }
}

// MARK: - The .gitignore every board carries

/// **"`.gitignore` seeded on every board, never touched after"** (06-history-undo.md ▸ Repository
/// hygiene, re-ruled 2026-07-31 — "the file outgrew git: it is the one noise definition the
/// loose-file relocation heal obeys … so every board carries it, git or not").
///
/// Three claims, and they are the whole ruling: **creation writes it**, **a board missing it gains
/// it by scheduled heal at open**, and **the app never edits an existing one** — an empty file
/// included, which is the ruling's own escape hatch. The gate it feeds is
/// `LooseFileRelocationTests` ▸ the noise gate; the pattern semantics are `GitignoreRulesTests`.
@MainActor
@Suite("Repository hygiene ▸ the .gitignore every board carries")
struct BoardGitignoreSeedTests {

    private func seedURL(in fixture: WriterFixture) -> URL {
        fixture.root.appendingPathComponent(IntegrityRules.gitignoreFileName)
    }

    private func stat(_ url: URL) throws -> (bytes: Data, modified: Date) {
        let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
        guard let modified = attributes[.modificationDate] as? Date else {
            throw NSError(domain: "BoardGitignoreSeedTests", code: 1)
        }
        return (try Data(contentsOf: url), modified)
    }

    @Test("Board creation writes the seed beside index.md")
    func creationSeeds() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        let root = fixture.url("New Board.kanban")

        try BoardWriter.createBoard(at: root, title: "New Board")

        #expect(try Data(contentsOf: root.appendingPathComponent(IntegrityRules.gitignoreFileName))
                    == Data(BoardWriter.gitignoreSeed.utf8))
    }

    @Test("A board missing the file gains it at open, silently")
    func healSeedsAtOpen() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
        #expect(!fixture.exists(IntegrityRules.gitignoreFileName))
        let store = try BoardStore(rootURL: fixture.root)

        store.runScheduledHeals()

        #expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
        // A courtesy file the user did not create and may not know exists — the guide's posture.
        #expect(store.banners.losses.isEmpty)
        #expect(store.banners.oneShots.isEmpty)
    }

    /// The heal's memo, doing its two jobs: a picture already acted on is not acted on again (no
    /// second write), and a picture that comes *back* — a foreign deletion — heals again, because the
    /// memo was cleared on success.
    @Test("Seeding twice writes once, and a deleted file comes back")
    func memoIsArmedAndCleared() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
        let store = try BoardStore(rootURL: fixture.root)

        store.seedGitignore()
        let first = try stat(seedURL(in: fixture))
        #expect(store.heals.memo(for: .missingGitignore) == nil, "cleared on success")

        store.seedGitignore()
        #expect(try stat(seedURL(in: fixture)) == first, "not rewritten — not even opened")

        // What a foreign deletion looks like: the picture "missing" is restored, and a standing memo
        // would have made that deletion the one thing this could not heal.
        try FileManager.default.removeItem(at: seedURL(in: fixture))
        store.seedGitignore()
        #expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data(BoardWriter.gitignoreSeed.utf8))
    }

    @Test("An existing .gitignore is left byte-for-byte alone, mtime included")
    func existingFileIsNeverRewritten() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
        let theirs = Data("# mine\nbuild/\n*.tmp\n".utf8)
        try fixture.file(IntegrityRules.gitignoreFileName, theirs)
        let before = try stat(seedURL(in: fixture))
        let store = try BoardStore(rootURL: fixture.root)

        store.runScheduledHeals()

        #expect(try fixture.data(IntegrityRules.gitignoreFileName) == theirs)
        #expect(try stat(seedURL(in: fixture)) == before, "never merged, never appended to, never opened")
    }

    /// "The escape hatch for wanting no exclusions is an *empty* file, which the app honors and never
    /// rewrites" — the one case where re-seeding would look most reasonable and is most wrong.
    @Test("An empty .gitignore is honored and never rewritten")
    func emptyFileIsHonored() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
        try fixture.file(IntegrityRules.gitignoreFileName, Data())
        let before = try stat(seedURL(in: fixture))
        let store = try BoardStore(rootURL: fixture.root)

        store.runScheduledHeals()
        store.runScheduledHeals()

        #expect(try fixture.data(IntegrityRules.gitignoreFileName) == Data())
        #expect(try stat(seedURL(in: fixture)) == before)
    }

    /// "Repo-nested boards are seeded too (re-ruling the old no-app-`.gitignore` posture): the file
    /// serves the heal there, not any app-managed git" — so there is no repo-detection gate on this
    /// heal, and the enclosing repository is still never written into.
    @Test("A repo-nested board is seeded like any other")
    func repoNestedBoardsAreSeeded() throws {
        let outer = try WriterFixture()
        defer { outer.tearDown() }
        try outer.file(".git/HEAD", Data("ref: refs/heads/main\n".utf8))
        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"))
        try Data(AgentGuide.content.utf8).write(to: boardRoot.appendingPathComponent(AgentGuide.filename))

        let store = try BoardStore(rootURL: boardRoot)
        store.runScheduledHeals()

        #expect(try Data(contentsOf: boardRoot.appendingPathComponent(IntegrityRules.gitignoreFileName))
                    == Data(BoardWriter.gitignoreSeed.utf8))
        #expect(!FileManager.default.fileExists(atPath: outer.root.appendingPathComponent(IntegrityRules.gitignoreFileName).path))
    }

    /// **The claimed name that does not displace** (`IntegrityRules.claimedRootNames`): a wrong-kind
    /// node wearing `.gitignore` is left exactly where it is, because a board with no readable noise
    /// definition simply excludes nothing — nothing breaks while the name is held, so nothing of the
    /// user's is moved to buy a courtesy file.
    @Test("A folder wearing the name is left alone, and nothing is written through it")
    func squatterIsLeftAlone() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
        try fixture.file("\(IntegrityRules.gitignoreFileName)/inside.txt", Data("mine".utf8))
        let store = try BoardStore(rootURL: fixture.root)

        store.runScheduledHeals()

        #expect(try fixture.data("\(IntegrityRules.gitignoreFileName)/inside.txt") == Data("mine".utf8))
        #expect(store.banners.oneShots.isEmpty, "and no failure is reported for work nobody asked for")
        #expect(store.banners.losses.isEmpty)
    }

    /// A board whose location cannot be written to defers rather than failing — the engine's gate,
    /// stated here because this heal runs at every open of every board and is the one most likely to
    /// meet a read-only volume.
    @Test("An unwritable board root is skipped silently")
    func unwritableRootIsSkipped() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
        let store = try BoardStore(rootURL: fixture.root)
        try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path)
        defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: fixture.root.path) }

        store.seedGitignore()

        #expect(!fixture.exists(IntegrityRules.gitignoreFileName))
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.heals.memo(for: .missingGitignore) == nil, "deferred, never remembered")
    }
}

// MARK: - The housekeeping pass

@MainActor
@Suite("Repository hygiene ▸ periodic housekeeping")
struct GitHousekeepingTests {

    @Test("A pass repacks loose objects and alters no commit, no ref, and no reachable content")
    func repackingChangesNothing() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        let looseBefore = GitHousekeeping.looseObjectCount(at: fixture.root)
        #expect(looseBefore > 0, "the fixture has to have something to pack")

        let objectsBefore = everyObject(at: fixture.root)
        let walkBefore = try historyWalk(at: fixture.root)
        let trackedBefore = GitRepository.trackedPaths(at: fixture.root)
        let refsBefore = try snapshot(fixture.root, ".git/refs")
        let headFileBefore = try snapshot(fixture.root, ".git/HEAD")
        let workingTreeBefore = try snapshot(fixture.root, "", skipping: [".git"])

        let outcome = GitHousekeeping.run(at: fixture.root, threshold: 1)
        guard case let .repacked(repack) = outcome else {
            Issue.record("expected a repack, got \(outcome)")
            return
        }

        // It did something…
        #expect(repack.looseBefore == looseBefore)
        #expect(repack.packedAway > 0)
        #expect(repack.packedAway == repack.inserted, "every inserted object was proved and removed")
        #expect(GitHousekeeping.looseObjectCount(at: fixture.root) < looseBefore)

        // …and it forgot nothing. Loose objects moved into a pack are the *same* objects: the whole
        // database answers for exactly the set it answered for before (06 ▸ Repository hygiene:
        // "it rewrites nothing").
        #expect(everyObject(at: fixture.root) == objectsBefore)

        // No commit, no ref, no reachable content.
        #expect(try historyWalk(at: fixture.root) == walkBefore)
        #expect(GitRepository.trackedPaths(at: fixture.root) == trackedBefore)
        #expect(try snapshot(fixture.root, ".git/refs") == refsBefore)
        #expect(try snapshot(fixture.root, ".git/HEAD") == headFileBefore)

        // And the working tree never came into it — housekeeping is a fact about `.git/objects` and
        // nothing else.
        #expect(try snapshot(fixture.root, "", skipping: [".git"]) == workingTreeBefore)
    }

    @Test("Every object is still readable after a pass, one oid at a time")
    func everyObjectSurvives() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        let before = everyObject(at: fixture.root)
        #expect(!before.isEmpty)

        _ = GitHousekeeping.run(at: fixture.root, threshold: 1)

        // The set comparison above is the same claim in aggregate; this is it per object, which is
        // the shape a corruption bug would actually take — one blob that went nowhere.
        let after = everyObject(at: fixture.root)
        for oid in before {
            #expect(after.contains(oid), "object \(oid) stopped being readable")
        }
    }

    @Test("Two passes in a row are stable — the second finds nothing left to do")
    func aSecondPassIsANoOp() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        _ = GitHousekeeping.run(at: fixture.root, threshold: 1)
        let objects = everyObject(at: fixture.root)
        let loose = GitHousekeeping.looseObjectCount(at: fixture.root)

        // Nothing re-packs what is already packed, so the second pass reads below any threshold the
        // first one left it under — and a repository that keeps being repacked would be growth, not
        // hygiene.
        #expect(GitHousekeeping.run(at: fixture.root, threshold: max(1, loose + 1)) == .belowThreshold(loose: loose))
        #expect(everyObject(at: fixture.root) == objects)
    }

    @Test("Below the threshold, the pass reads the count and does nothing at all")
    func belowThresholdTouchesNothing() 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 before = try snapshot(fixture.root, ".git")
        let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
        #expect(loose > 0)

        #expect(GitHousekeeping.run(at: fixture.root, threshold: loose + 1) == .belowThreshold(loose: loose))
        #expect(try snapshot(fixture.root, ".git") == before, "a gate that closed wrote nothing")
    }

    @Test("The default threshold is git's own gc.auto, so an ordinary board is never repacked at open")
    func theDefaultThresholdIsGitsOwn() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        #expect(GitHousekeeping.defaultLooseObjectThreshold == 6700)
        let loose = GitHousekeeping.looseObjectCount(at: fixture.root)
        #expect(GitHousekeeping.run(at: fixture.root) == .belowThreshold(loose: loose))
    }

    @Test("A paused repository is skipped in silence, and stays untouched")
    func aPausedRepositoryIsSkipped() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        // The marker file libgit2's own `git_repository_state` reads — an outside-the-app merge, in
        // progress. The committer holds for this (06 ▸ Rules ▸ Abnormal repo states); optional work
        // simply does not happen.
        let head = try #require(GitRepository.headCommit(at: fixture.root))
        try fixture.file(".git/MERGE_HEAD", Data("\(head.oid)\n".utf8))
        let before = try snapshot(fixture.root, ".git")

        #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.held))
        #expect(try snapshot(fixture.root, ".git") == before)
    }

    @Test("A held index.lock is skipped in silence, and stays untouched")
    func aHeldLockIsSkipped() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        // Another writer, mid-operation. The lock is never removed, whatever the app is doing —
        // it isn't the app's (06 ▸ Interaction with external writers).
        try fixture.file(".git/index.lock", Data())
        let before = try snapshot(fixture.root, ".git")

        #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.indexLocked))
        #expect(try snapshot(fixture.root, ".git") == before)
        #expect(fixture.exists(".git/index.lock"), "and the lock is still somebody else's")
    }

    @Test("Deleting a card leaves every prior commit touching its folder fully intact")
    func deletingNeverForgets() 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 cardPath = "\(Ident.lane1)/\(Ident.card1)/index.md"
        let birth = try #require(GitRepository.headCommit(at: fixture.root))
        let content = try fixture.data(cardPath)
        let contentOID = try #require(blobOID(of: content))
        #expect(everyObject(at: fixture.root).contains(contentOID), "the card's bytes are in the repository")

        // A delete, in both of its shapes: into `.trash/`, then gone for good.
        try fixture.moveFolder("\(Ident.lane1)/\(Ident.card1)", to: ".trash/\(Ident.card1)")
        try commitEverything(at: fixture.root, message: "Delete card 'First'")
        try FileManager.default.removeItem(at: fixture.url(".trash/\(Ident.card1)"))
        try commitEverything(at: fixture.root, message: "Permanently delete card 'First'")

        // Off the live board, and out of the working tree…
        #expect(!GitRepository.trackedPaths(at: fixture.root).contains(cardPath))
        #expect(!fixture.exists("\(Ident.lane1)/\(Ident.card1)"))

        // …and every version of its content still reachable in the repository, with the commit that
        // introduced it exactly where it was. "Deleting never forgets" (06 ▸ Repository hygiene) is
        // stated design, and there is no code path in the app that could take it back: nothing
        // rewrites history, and housekeeping packs rather than prunes.
        _ = GitHousekeeping.run(at: fixture.root, threshold: 1)
        let objects = everyObject(at: fixture.root)
        #expect(objects.contains(contentOID), "the deleted card's bytes are still in the object database")
        #expect(objects.contains(birth.oid), "and so is the commit that introduced them")
        #expect(try historyWalk(at: fixture.root).last == "\(birth.oid) \(birth.subject)")
    }

    @Test("A board with no repository is skipped, and no repository appears")
    func aBoardWithNoRepositoryIsSkipped() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        #expect(GitHousekeeping.run(at: fixture.root, threshold: 1) == .skipped(.noRepository))
        #expect(!fixture.exists(".git"))
    }
}

// MARK: - The scheduler

@MainActor
@Suite("Repository hygiene ▸ when housekeeping runs")
struct GitHousekeeperSchedulingTests {

    @Test("A git-mode store composes a housekeeper; a mode-none one composes none")
    func compositionFollowsMode() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

        let plain = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(plain.housekeeper == nil, "no repository, nothing to maintain")

        #expect(await plain.addGit())
        #expect(plain.housekeeper != nil, "the mid-session flip maintains itself like any git board")

        let reopened = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        #expect(reopened.housekeeper != nil)
    }

    @Test("The free tier has no housekeeper anywhere, because it has no git state at all")
    func theFreeTierMaintainsNothing() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        // Structural, not conditional: with no `HistoryStore` there is no housekeeper to disable and
        // no code path that could reach one (12-editions.md ▸ The free tier and `.git`).
        #expect(HistoryStore.compose(boardRoot: fixture.root, tier: .free) == nil)
    }

    @Test("A commit in flight defers the pass entirely — it is never retried")
    func aCommitInFlightDefersThePass() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        let housekeeper = try #require(git.housekeeper)
        housekeeper.threshold = 1
        housekeeper.isCommitInFlight = { true }

        let before = try snapshot(fixture.root, ".git")
        await housekeeper.runNow()
        #expect(housekeeper.lastOutcome == nil, "it did not run, and recorded no verdict")
        #expect(try snapshot(fixture.root, ".git") == before)

        // And with the engine quiet it runs — the same pass, one board-open later.
        housekeeper.isCommitInFlight = { false }
        await housekeeper.runNow()
        guard case .repacked = housekeeper.lastOutcome else {
            Issue.record("expected a repack once the committer was quiet, got \(String(describing: housekeeper.lastOutcome))")
            return
        }
    }

    @Test("Activating auto-commit arms the pass, and teardown cancels it")
    func activationArmsAndTeardownCancels() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }

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

        let git = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        let housekeeper = try #require(git.housekeeper)
        housekeeper.threshold = 1
        housekeeper.delay = .milliseconds(20)
        git.committer?.debounceInterval = .seconds(60)

        git.activateAutoCommit { _ in }
        try await Task.sleep(for: .milliseconds(400))
        guard case .repacked = housekeeper.lastOutcome else {
            Issue.record("board open arms one pass, got \(String(describing: housekeeper.lastOutcome))")
            return
        }

        // Teardown cancels an armed one, so a closed board's maintenance cannot fire against a store
        // that has gone.
        let second = try #require(HistoryStore.compose(boardRoot: fixture.root, tier: .pro))
        let secondKeeper = try #require(second.housekeeper)
        secondKeeper.threshold = 1
        secondKeeper.delay = .milliseconds(200)
        second.activateAutoCommit { _ in }
        second.stopAutoCommit()
        try await Task.sleep(for: .milliseconds(500))
        #expect(secondKeeper.lastOutcome == nil)
    }

    @Test("The wired gate is the committer's own in-flight flag")
    func theGateIsTheCommittersOwnFlag() 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 housekeeper = try #require(git.housekeeper)
        let committer = try #require(git.committer)
        #expect(committer.isCommitInFlight == false, "a quiet engine")
        #expect(housekeeper.isCommitInFlight?() == false, "and the housekeeper reads it, not a copy")
    }
}
