import Foundation
import Testing
@testable import Kanban

/// **The file layer never touches `.git`, stated against real bytes on disk** — every write and every
/// load the app makes leaves the whole `.git` subtree byte- and mtime-identical.
///
/// ### What this file used to be, and what it is now
///
/// It was written as the **inert-`.git` posture**'s byte-level proof (12-editions.md ▸ The free tier
/// and `.git`, as it then read: "any `.git` is inert … the app never reads history, never commits,
/// never touches `.git` in any way"). **PIVOT 2026-08-07** retired that posture, if only for a day —
/// git left the paywall, so a `.git` at a board root became *live* in every tier, detection ran at
/// every open, and an auto-committer wrote into exactly the directory this file's fixture plants.
///
/// **EXCISION 2026-08-08** (`strategy/01-git-excision.md`) retired the pivot in turn: app-managed
/// git is gone outright. There is no mode detection, no auto-committer, nothing left anywhere under
/// `Kanban/` that opens `.git` for writing, on any board at all. What the inert posture claimed
/// once, and the pivot scoped down to a single tier for a single day, is again the app's whole git
/// story — this time app-wide rather than tier-gated, and for good rather than pending a paywall
/// decision. This suite is that restored claim's proof.
///
/// The tests survived both transitions unchanged because none of them ever composed a `HistoryStore`
/// or asserted anything about a tier: they drive `BoardWriter` and `BoardLoader` directly, and what
/// they pin is that **those layers are git-agnostic**. That claim was true before the pivot, stayed
/// true underneath the pivot's live machinery, and is now — again — the whole of the app's git
/// story, load-bearing in two places a tier-gated posture never could have reached uniformly:
///
/// - **Every board** is a board the app manages no git for. Before the pivot that was true because
///   nothing read `.git` at all; during the pivot it was true only for boards nobody had added git
///   to (mode `none`); since the excision it is true unconditionally, because there is no mode left
///   to be in. Nothing but this layer runs on any board, so this layer's indifference *is* the whole
///   promise.
/// - **A `.git` the app does not manage** — a repo-nested board's ancestor, a clone dropped inside a
///   card folder (which the fixture below plants deliberately) — is left strictly alone by the same
///   indifference, unconditionally now rather than "whatever the board's own mode or the user's
///   tier."
///
/// Two neighbouring claims are pinned elsewhere and are referenced, not repeated: `FolderWatcherTests`
/// ▸ ".git filtering" proves the watcher ignores churn under a `.git` at any depth, and
/// `BoardLoaderStrayTests.strayFilesAndHiddenEntriesAreIgnoredWithoutWarning` proves a `.git` at the
/// board root loads as an ordinary stray with no warning. Both are *input* claims: what the app does
/// with events and entries it is handed.
///
/// This file states the **output** claim: a full session of ordinary editing leaves every byte and
/// every mtime under `.git` exactly as it found them. It is asserted the only way
/// that is worth anything — by snapshotting the whole `.git` subtree from the filesystem before
/// the edits and re-reading it afterwards, never through the app's own read path
/// (`WriterTestSupport.swift`'s standing rule).
///
/// Note what "untouched" is worth as an mtime assertion specifically: bytes alone would pass even
/// if the app rewrote a file with identical content, and re-writing git's index with identical
/// bytes is exactly the kind of thing an accidental git dependency would do. The mtimes are the
/// assertion that nothing *opened for writing* down there at all.

// MARK: - Subtree snapshots

/// One entry under `.git`: its path relative to the board root, its bytes (nil for directories),
/// and its on-disk modification date. Directories carry an mtime too — a file created or removed
/// inside a directory moves *that directory's* mtime, so including them catches a write the
/// per-file comparison would miss because the file it added is not in the "before" set.
private struct SubtreeEntry: Equatable, CustomStringConvertible {
    let relativePath: String
    let data: Data?
    let modified: Date

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

/// Every entry beneath `root/subtree`, hidden entries included, sorted by path. `.git` is itself
/// hidden and everything inside it is reached through it, so `.skipsHiddenFiles` is deliberately
/// *not* passed — a snapshot that skipped hidden files would snapshot nothing at all.
private func snapshotSubtree(_ root: URL, _ subtree: String) throws -> [SubtreeEntry] {
    let base = root.appendingPathComponent(subtree, isDirectory: true)
    let manager = FileManager.default
    guard let walker = manager.enumerator(atPath: base.path) else {
        Issue.record("could not enumerate \(subtree)")
        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 {
            Issue.record("no modification date for \(subtree)/\(relative)")
            continue
        }
        let isDirectory = (attributes[.type] as? FileAttributeType) == .typeDirectory
        entries.append(SubtreeEntry(
            relativePath: "\(subtree)/\(relative)",
            data: isDirectory ? nil : try Data(contentsOf: url),
            modified: modified
        ))
    }

    // The directory the subtree hangs from, which the enumerator above does not yield.
    let rootAttributes = try manager.attributesOfItem(atPath: base.path)
    if let modified = rootAttributes[.modificationDate] as? Date {
        entries.append(SubtreeEntry(relativePath: subtree, data: nil, modified: modified))
    }

    return entries.sorted { $0.relativePath < $1.relativePath }
}

// MARK: - Fixture construction

/// A `.git` directory with the shape a real one has — a ref file, a binary index, a loose object
/// two levels down, a packed-refs file, and an empty `objects/pack` — written under `parent`.
/// The bytes are sentinels: recognizable, non-UTF-8 in the index's case, and nothing the app has
/// any reader for.
@discardableResult
private func makeGitDirectory(in fixture: WriterFixture, under parent: String) throws -> String {
    let prefix = parent.isEmpty ? ".git" : "\(parent)/.git"

    try fixture.file("\(prefix)/HEAD", Data("ref: refs/heads/main\n".utf8))
    try fixture.file("\(prefix)/config", Data("[core]\n\trepositoryformatversion = 0\n".utf8))
    // Deliberately not valid UTF-8 — a real `.git/index` is binary, and a byte comparison that
    // only ever sees text is not testing the thing that matters.
    try fixture.file("\(prefix)/index", Data([0x44, 0x49, 0x52, 0x43, 0x00, 0x00, 0x00, 0x02, 0xFF, 0xFE, 0x00, 0x01]))
    try fixture.file("\(prefix)/objects/ab/cdef0123456789", Data([0x78, 0x01, 0xCB, 0xC8, 0x4F, 0x00, 0x00]))
    try fixture.file("\(prefix)/refs/heads/main", Data("0123456789abcdef0123456789abcdef01234567\n".utf8))
    try fixture.file("\(prefix)/packed-refs", Data("# pack-refs with: peeled fully-peeled sorted\n".utf8))
    try FileManager.default.createDirectory(
        at: fixture.url("\(prefix)/objects/pack"),
        withIntermediateDirectories: true
    )

    return prefix
}

/// The board every test here edits: a root, two lanes, three cards, a `.git` at the board root
/// **and** a second one nested inside a card folder. Both, deliberately: the nested one is a `.git`
/// the app never manages under any ruling (a clone dropped inside a card), and the root one is the
/// case the pivot changed — it is a live repository to the *git* layer now, and still nothing at all
/// to the file layer these tests drive.
private struct UntouchedGitBoard {
    let fixture: WriterFixture
    let laneA: String
    let laneB: String
    let card1: String
    let card2: String
    /// The card carrying the nested repo — the one the move test drags across lanes.
    let cardWithRepo: String

    init() throws {
        fixture = try WriterFixture()
        laneA = Ident.lane1
        laneB = Ident.lane2
        card1 = Ident.card1
        card2 = Ident.card2
        cardWithRepo = Ident.card3

        try fixture.item("", Item.board)
        try fixture.item(laneA, Item.rich(order: "1024", title: "Doing"))
        try fixture.item(laneB, Item.rich(order: "2048", title: "Done"))
        try fixture.item("\(laneA)/\(card1)", Item.rich(order: "1024", title: "First"))
        try fixture.item("\(laneA)/\(card2)", Item.rich(order: "2048", title: "Second"))
        try fixture.item("\(laneA)/\(cardWithRepo)", Item.rich(order: "3072", title: "Has a clone"))

        try makeGitDirectory(in: fixture, under: "")
        try makeGitDirectory(in: fixture, under: "\(laneA)/\(cardWithRepo)")
    }

    var root: URL { fixture.root }
    func url(_ relativePath: String) -> URL { fixture.url(relativePath) }
    func tearDown() { fixture.tearDown() }
}

// MARK: - The claim

@Suite("The file layer never touches `.git`")
struct UntouchedGitTests {
    @Test("A full session of ordinary edits leaves every byte and mtime under .git untouched")
    func anEditingSessionNeverTouchesGit() throws {
        let board = try UntouchedGitBoard()
        defer { board.tearDown() }

        let before = try snapshotSubtree(board.root, ".git")
        #expect(before.count == 12, "the fixture's own shape — files, directories and the root")

        // A session's worth of every write the app can make, in one go. `.git` is at the
        // board root, so anything that walks or renumbers the root's children walks past it.
        try BoardWriter.updateIndex(inItemFolder: board.url(""), operation: .rename(title: nil)) { document in
            document.set(FrontmatterKeys.title, to: .string("Renamed board"))
        }
        let newLane = try BoardWriter.createLane(inBoard: board.root, title: "Later")
        let newCard = try BoardWriter.createCard(inLane: board.url(newLane.rawValue), title: "Fresh")
        try BoardWriter.writeBody(inItemFolder: board.url("\(newLane.rawValue)/\(newCard.rawValue)"), body: "Body text.\n")
        try BoardWriter.updateIndex(
            inItemFolder: board.url("\(board.laneA)/\(board.card1)"),
            operation: .style(title: nil)
        ) { document in
            document.set(FrontmatterKeys.title, to: .string("Retitled"))
        }
        _ = try BoardWriter.moveItem(
            at: board.url("\(board.laneA)/\(board.card2)"),
            toParent: board.url(board.laneB),
            sourceBoardRoot: board.root,
            destinationBoardRoot: board.root,
            order: nil
        )
        _ = try BoardWriter.copyItem(
            at: board.url("\(board.laneA)/\(board.card1)"),
            toParent: board.url(board.laneB),
            order: nil,
            stamps: .fork
        )
        // The delete and its restore are both folder moves now (03-board-ui.md § Trash), which is
        // the pair most likely to notice a `.git` at the root: the first walks into `<root>/.trash/`
        // and the second walks back out of it.
        try BoardWriter.deleteCardToTrash(
            at: board.url("\(board.laneA)/\(board.card1)"), inBoard: board.root
        )
        _ = try BoardWriter.moveItem(
            at: BoardWriter.trashFolder(inBoard: board.root).appendingPathComponent(board.card1),
            toParent: board.url(board.laneA),
            sourceBoardRoot: board.root,
            destinationBoardRoot: board.root,
            order: nil
        )
        // The renumbers are the pointed ones: both walk a parent's whole directory listing, which
        // is where a `.git` entry actually gets looked at.
        try BoardWriter.renumberVisibleChildren(of: board.root)
        try BoardWriter.renumberVisibleChildren(of: board.url(board.laneA))

        #expect(try snapshotSubtree(board.root, ".git") == before)
    }

    @Test("The board loads and renders normally with a .git at its root, without a warning")
    func aGitBearingBoardLoadsLikeAnyOther() throws {
        let board = try UntouchedGitBoard()
        defer { board.tearDown() }

        let before = try snapshotSubtree(board.root, ".git")

        let result = try BoardLoader.load(boardRoot: board.root)

        #expect(result.model.lanes.map(\.id.rawValue) == [board.laneA, board.laneB])
        #expect(result.model.lanes[0].cards.map(\.id.rawValue) == [board.card1, board.card2, board.cardWithRepo])
        #expect(result.warnings.isEmpty, "a `.git` is a stray like any other — strays are silent")
        // A load is a read, but a read that opened `.git` would still move its atimes and would
        // still be the app "touching" history; the mtime equality is what is checkable, and a
        // loader that decided to *repair* something down there would break it.
        #expect(try snapshotSubtree(board.root, ".git") == before)
    }

    @Test("A nested .git rides along a card move byte- and mtime-verbatim")
    func aNestedRepositorySurvivesACardMove() throws {
        let board = try UntouchedGitBoard()
        defer { board.tearDown() }

        let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
        let before = try snapshotSubtree(board.root, nested)

        let result = try BoardWriter.moveItem(
            at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
            toParent: board.url(board.laneB),
            sourceBoardRoot: board.root,
            destinationBoardRoot: board.root,
            order: nil
        )

        // Same identity, new parent — the folder moved whole, `.git` inside it.
        let moved = "\(board.laneB)/\(result.id.rawValue)/.git"
        let after = try snapshotSubtree(board.root, moved)

        #expect(after.map(\.data) == before.map(\.data))
        #expect(after.map(\.modified) == before.map(\.modified))
        #expect(
            after.map { $0.relativePath.replacingOccurrences(of: moved, with: nested) } == before.map(\.relativePath)
        )
    }

    @Test("A copy of a card carrying a .git reproduces it verbatim and leaves the original alone")
    func aCopyCarriesTheNestedRepositoryWithoutTouchingTheOriginal() throws {
        let board = try UntouchedGitBoard()
        defer { board.tearDown() }

        let nested = "\(board.laneA)/\(board.cardWithRepo)/.git"
        let before = try snapshotSubtree(board.root, nested)

        let copyID = try BoardWriter.copyItem(
            at: board.url("\(board.laneA)/\(board.cardWithRepo)"),
            toParent: board.url(board.laneB),
            order: nil,
            stamps: .fork
        )

        #expect(try snapshotSubtree(board.root, nested) == before, "the source is never touched")

        // The copy's `.git` is the same tree with the same bytes (mtimes are a copy's to set —
        // `FileManager.copyItem` preserves them, but the promise being made here is about content
        // and shape, not about a copy having been a rename).
        let copied = try snapshotSubtree(board.root, "\(board.laneB)/\(copyID.rawValue)/.git")
        #expect(copied.map(\.data) == before.map(\.data))
        #expect(
            copied.map { $0.relativePath.replacingOccurrences(of: "\(board.laneB)/\(copyID.rawValue)/.git", with: nested) }
                == before.map(\.relativePath)
        )
    }
}
