import Foundation
import Testing
@testable import Kanban

/// The agent guide, end to end (08-agent-integration.md ▸ The agent guide).
///
/// The rule is three promises, and this file is organized as them:
///
/// 1. **The marker decides, and only the first line carries it** — the version gate, parsed as a
///    pure function of text.
/// 2. **The decision is pure** — write, leave alone, displace, or skip, from what the two claimed
///    board-root names look like on disk and nothing else.
/// 3. **Nothing the user owns is ever destroyed** — a markerless `CLAUDE.md` is rescued, a taken
///    `CLAUDE.user.md` cancels the write outright, a symlink or a folder wearing the name is
///    *displaced* rather than clobbered (ruled 2026-07-29 — the claimed-name rule; it replaced an
///    untouchable-skip, and displacement-never-destruction is what survives), and a current guide is
///    not even opened for writing.
///
/// Every on-disk claim is read back as **raw bytes**, never through a snapshot: the promises are
/// about the files. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

// MARK: - Shared fixtures

/// A one-lane board — enough tree that a reload has something to walk.
///
/// It carries the **seeded `.gitignore`**, which is what any board the app has opened once looks
/// like (06-history-undo.md ▸ Repository hygiene, re-ruled 2026-07-31). Without it the store's own
/// seeding heal — which runs on every successful reload beside this file's guide refresh — would
/// write that file on the first reload and open a bracket of its own, and the bracket counts below
/// would stop being claims about the guide. (`LooseFileRelocationTests`' fixture carries the guide
/// for the mirror-image reason.)
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.file(IntegrityRules.gitignoreFileName, Data(BoardWriter.gitignoreSeed.utf8))
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    return fixture
}

/// A guide-shaped file at some version — the shape an older app version left behind, or one a newer
/// version will.
private func guideText(version: Int) -> String {
    "<!-- lanework-agent-guide v\(version) — created and kept up to date by the Lanework app. -->\n\n# This folder is a Lanework kanban board\n"
}

/// A file's bytes and mtime — "this file was not rewritten", stated the way `LooseFileRelocationTests`
/// states it.
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: "AgentGuideTests", code: 1)
    }
    return (try Data(contentsOf: url), modified)
}

private func guideURL(in fixture: WriterFixture) -> URL {
    fixture.root.appendingPathComponent(AgentGuide.filename)
}

/// The `AGENTS.md` twin's URL — `guideURL`'s counterpart, for the tests that pin the twin promise.
private func agentsURL(in fixture: WriterFixture) -> URL {
    fixture.root.appendingPathComponent(AgentGuide.agentsFilename)
}

private func userFileURL(in fixture: WriterFixture) -> URL {
    fixture.root.appendingPathComponent(AgentGuide.userFilename)
}

/// Writes raw bytes to one of the two claimed board-root names, without going near the app.
@discardableResult
private func writeRoot(_ name: String, _ bytes: Data, in fixture: WriterFixture) throws -> URL {
    let url = fixture.root.appendingPathComponent(name)
    try bytes.write(to: url)
    return url
}

/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up.
@MainActor
private final class GuideBracketLog {
    private(set) var begins = 0

    func attach(to store: BoardStore) {
        store.watcherBrackets = (begin: { self.begins += 1 }, end: {})
    }
}

/// Polls until `condition` holds or the deadline passes — generous, because FSEvents delivery is not
/// a bounded-latency promise (`BoardStoreRegistryTests`' idiom).
@MainActor
private func waitUntil(_ deadline: Duration = .seconds(15), _ condition: () -> Bool) async {
    let start = ContinuousClock.now
    while ContinuousClock.now - start < deadline {
        if condition() { return }
        try? await Task.sleep(for: .milliseconds(25))
    }
}

/// Gives a freshly started stream a beat to register with `fseventsd` — see `FolderWatcherTests`.
@MainActor
private func settle() async {
    try? await Task.sleep(for: .milliseconds(300))
}

// MARK: - 1. The version marker

@Suite("Agent guide ▸ the marker")
struct AgentGuideMarkerTests {

    @Test("The guide the app ships carries the current marker in its first line")
    func shippedGuideCarriesTheMarker() {
        let firstLine = String(AgentGuide.content.prefix { !$0.isNewline })
        #expect(firstLine.contains("lanework-agent-guide v\(AgentGuide.version)"))
        #expect(AgentGuide.installedVersion(of: AgentGuide.content) == AgentGuide.version)
        // Files the app creates end with LF (01-storage-format.md § Encoding and line endings).
        #expect(AgentGuide.content.hasSuffix("\n"))
        #expect(!AgentGuide.content.hasSuffix("\n\n"))
    }

    @Test("Any version in the first line parses")
    func versionsParse() {
        #expect(AgentGuide.installedVersion(of: guideText(version: 1)) == 1)
        #expect(AgentGuide.installedVersion(of: guideText(version: 4)) == 4)
        #expect(AgentGuide.installedVersion(of: guideText(version: 12)) == 12)
    }

    @Test("A file with no marker has no version")
    func markerlessHasNoVersion() {
        #expect(AgentGuide.installedVersion(of: "# My project\n\nNotes for agents.\n") == nil)
        #expect(AgentGuide.installedVersion(of: "") == nil)
        // The prefix without a number is not a marker either.
        #expect(AgentGuide.installedVersion(of: "lanework-agent-guide vNext\n") == nil)
    }

    /// **The deliberate divergence from the pathfinder** (08: "version marker in the first line"):
    /// a user's own file that merely *quotes* the marker further down is not an app-owned guide, and
    /// reading it as one would overwrite it.
    @Test("A marker below the first line does not count")
    func markerBelowTheFirstLineIsNotAMarker() {
        #expect(AgentGuide.installedVersion(of: "# Notes\nlanework-agent-guide v5\n") == nil)
        #expect(AgentGuide.installedVersion(of: "\nlanework-agent-guide v9\n") == nil)
    }

    @Test("CRLF and a missing final newline both parse")
    func lineEndingsDoNotMatter() {
        #expect(AgentGuide.installedVersion(of: "<!-- lanework-agent-guide v3 -->\r\nbody\r\n") == 3)
        #expect(AgentGuide.installedVersion(of: "<!-- lanework-agent-guide v3 -->") == 3)
    }
}

// MARK: - 2. The decision

@Suite("Agent guide ▸ the decision")
struct AgentGuideDecisionTests {

    private func state(_ existing: AgentGuide.Existing, userFileFree: Bool = true) -> AgentGuide.State {
        AgentGuide.State(existing: existing, userFilenameIsFree: userFileFree)
    }

    @Test("Missing writes; older rewrites")
    func missingAndOlderWrite() {
        #expect(AgentGuide.decide(state(.missing)) == .write)
        #expect(AgentGuide.decide(state(.file(text: guideText(version: 1)))) == .write)
        #expect(AgentGuide.decide(state(.file(text: guideText(version: 4)))) == .write)
    }

    /// "Never downgraded" — a newer app version may have written it.
    @Test("Current and newer are left alone")
    func currentAndNewerAreLeftAlone() {
        #expect(AgentGuide.decide(state(.file(text: guideText(version: AgentGuide.version)))) == .leaveAlone)
        #expect(AgentGuide.decide(state(.file(text: guideText(version: AgentGuide.version + 1)))) == .leaveAlone)
        #expect(AgentGuide.decide(state(.file(text: AgentGuide.content))) == .leaveAlone)
    }

    @Test("A markerless file is displaced when the user name is free")
    func markerlessIsDisplaced() {
        #expect(AgentGuide.decide(state(.file(text: "# My own notes\n"))) == .displaceThenWrite)
        // Undecodable bytes are markerless too — the move preserves them either way.
        #expect(AgentGuide.decide(state(.file(text: nil))) == .displaceThenWrite)
        // A marker that is not on the first line is a user file, not an old guide.
        #expect(AgentGuide.decide(state(.file(text: "# Notes\nlanework-agent-guide v4\n"))) == .displaceThenWrite)
    }

    /// "Otherwise the guide write is skipped with a log — user content is never destroyed."
    @Test("A taken CLAUDE.user.md cancels the write outright")
    func takenUserFilenameSkips() {
        #expect(AgentGuide.decide(state(.file(text: "# My own notes\n"), userFileFree: false)) == .skipUserFilenameTaken)
        #expect(AgentGuide.decide(state(.file(text: nil), userFileFree: false)) == .skipUserFilenameTaken)
        // It has no bearing on the version gate: an app-owned guide is still upgraded in place.
        #expect(AgentGuide.decide(state(.file(text: guideText(version: 4)), userFileFree: false)) == .write)
        #expect(AgentGuide.decide(state(.missing, userFileFree: false)) == .write)
    }

    /// **Updated 2026-07-29** — the claimed-name squatter ruling (01-storage-format.md § Fractal
    /// layout ▸ Rules) upgraded this case from a skip to a displacement: Lanework owns the board, so
    /// a folder or symlink on a name the app claims is an invalid artifact, not a resident. It is
    /// moved aside by the Finder ladder and never destroyed.
    ///
    /// **Free name or not is still irrelevant here**, but for a new reason: `CLAUDE.user.md` is the
    /// *rescue* destination for user content, and a squatter is not rescued to it — it goes to
    /// `CLAUDE.md 2`, so the other name's state has no bearing on the decision.
    @Test("A symlink or a folder is displaced, free name or not")
    func squatterIsDisplaced() {
        #expect(AgentGuide.decide(state(.squatted)) == .displaceSquatterThenWrite)
        #expect(AgentGuide.decide(state(.squatted, userFileFree: false)) == .displaceSquatterThenWrite)
    }
}

// MARK: - 3. On disk, through the store

@MainActor
@Suite("Agent guide ▸ on disk")
struct AgentGuideStoreTests {

    @Test("A board with no CLAUDE.md gets the current guide")
    func missingGuideIsWritten() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()

        let written = try String(decoding: fixture.data(AgentGuide.filename), as: UTF8.self)
        #expect(written.prefix { !$0.isNewline }.contains("lanework-agent-guide v\(AgentGuide.version)"))
        #expect(written == AgentGuide.content)
        #expect(!fixture.exists(AgentGuide.userFilename))
        #expect(brackets.begins == 1, "one bracket — one app-mediated reload, one commit")
        // A courtesy file the user never asked for has nothing to say to them.
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.banners.losses.isEmpty)
        // No temp-file residue from the atomic replace.
        #expect(try fixture.entryNames("").contains(AgentGuide.filename))
        #expect(try !fixture.entryNames("").contains { $0.hasPrefix(".\(AgentGuide.filename)") })
    }

    @Test("An older guide is rewritten to the current one")
    func olderGuideIsUpgraded() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        // An upgrade is not a displacement: nothing was rescued, because nothing was the user's.
        #expect(!fixture.exists(AgentGuide.userFilename))
    }

    /// "Left untouched when current or newer" — and untouched means the file is not even opened for
    /// writing, so its mtime is undisturbed.
    @Test("A current guide is left byte-for-byte alone, mtime included")
    func currentGuideIsNotRewritten() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // Deliberately *not* the app's own text: a current marker is the whole gate, and a file
        // carrying one must survive verbatim even when its body differs from what this version
        // would write. Both claimed names, so the twin doesn't itself count as work needing a
        // bracket — this test is about the current-guide gate, not the twin write.
        let url = try writeRoot(AgentGuide.filename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
        let agentsURLValue = try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: AgentGuide.version).utf8), in: fixture)
        let before = try stat(url)
        let agentsBefore = try stat(agentsURLValue)
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()

        let after = try stat(url)
        let agentsAfter = try stat(agentsURLValue)
        #expect(after.bytes == before.bytes)
        #expect(after.modified == before.modified)
        #expect(agentsAfter.bytes == agentsBefore.bytes)
        #expect(agentsAfter.modified == agentsBefore.modified)
        #expect(brackets.begins == 0, "no write means no bracket and no commit")
    }

    @Test("A newer guide is never downgraded")
    func newerGuideIsNotDowngraded() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let url = try writeRoot(AgentGuide.filename, Data(guideText(version: 99).utf8), in: fixture)
        let before = try stat(url)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        let after = try stat(url)
        #expect(after.bytes == before.bytes)
        #expect(after.modified == before.modified)
    }

    @Test("A markerless CLAUDE.md moves to CLAUDE.user.md, byte for byte, and the guide takes its place")
    func markerlessIsDisplaced() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let mine = "# Board rules\n\nAlways file bugs in the Triage lane.\n"
        try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()

        #expect(try fixture.data(AgentGuide.userFilename) == Data(mine.utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        #expect(brackets.begins == 1, "the rescue and the write ride one bracket")
        #expect(store.banners.oneShots.isEmpty)
    }

    /// The first-line rule, proved where it costs something: a user file quoting the marker is
    /// rescued, not overwritten.
    @Test("A marker below the first line is user content and is displaced")
    func markerBelowTheFirstLineIsDisplaced() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let mine = "# Notes about Lanework\n\nThe app stamps `lanework-agent-guide v5` into CLAUDE.md.\n"
        try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data(AgentGuide.userFilename) == Data(mine.utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    @Test("A CLAUDE.md that is not UTF-8 is displaced with its bytes intact")
    func undecodableIsDisplacedByteForByte() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let bytes = Data([0xFF, 0xFE, 0x00, 0x41, 0x80, 0x0A])
        try writeRoot(AgentGuide.filename, bytes, in: fixture)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data(AgentGuide.userFilename) == bytes)
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    /// "Otherwise the guide write is skipped with a log — user content is never destroyed." Three
    /// files the user owns (both claimed guide names, foreign, plus the rescue destination already
    /// taken), all still theirs afterwards, and no guide on this board at all.
    @Test("A taken CLAUDE.user.md leaves both files alone and writes no guide")
    func takenUserFilenameSkipsTheWrite() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let mine = "# Board rules\n"
        let mineToo = "# Board rules, the AGENTS.md copy\n"
        let theirs = "# Older board rules\n"
        let guide = try writeRoot(AgentGuide.filename, Data(mine.utf8), in: fixture)
        let agentsGuide = try writeRoot(AgentGuide.agentsFilename, Data(mineToo.utf8), in: fixture)
        let user = try writeRoot(AgentGuide.userFilename, Data(theirs.utf8), in: fixture)
        let guideBefore = try stat(guide)
        let agentsGuideBefore = try stat(agentsGuide)
        let userBefore = try stat(user)
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()

        #expect(try stat(guide).bytes == guideBefore.bytes)
        #expect(try stat(guide).modified == guideBefore.modified)
        #expect(try stat(agentsGuide).bytes == agentsGuideBefore.bytes)
        #expect(try stat(agentsGuide).modified == agentsGuideBefore.modified)
        #expect(try stat(user).bytes == userBefore.bytes)
        #expect(try stat(user).modified == userBefore.modified)
        #expect(brackets.begins == 0)
        #expect(store.banners.oneShots.isEmpty, "a skip is a log line, not a banner")
    }

    /// **Updated 2026-07-29** — the claimed-name squatter ruling. A symlink wearing the guide's name
    /// is still never *followed*: it is moved aside **as a link** (`lstat` semantics all the way
    /// down), its target is never opened, and the guide is written on the freed name.
    @Test("A symlinked CLAUDE.md is displaced as a link, and its target is untouched")
    func symlinkedGuideIsDisplaced() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let target = try fixture.file("elsewhere.md", Data("# somewhere else\n".utf8))
        try FileManager.default.createSymbolicLink(atPath: guideURL(in: fixture).path, withDestinationPath: "elsewhere.md")
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        // The link moved, still a link, still pointing where it pointed — never resolved, never
        // written through.
        // Finder's own splitting: `CLAUDE.md` → `CLAUDE 2.md` (the ladder splits after the last
        // dot), exactly as `.trash` → `.trash 2` for an extension-less name.
        let moved = fixture.root.appendingPathComponent("CLAUDE 2.md")
        #expect(try FileManager.default.destinationOfSymbolicLink(atPath: moved.path) == "elsewhere.md")
        #expect(try Data(contentsOf: target) == Data("# somewhere else\n".utf8))
        // And the freed name now carries the guide.
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        // A squatter's displacement is announced — the relocation-style warning-tone notice, naming
        // old and new. The rescue to CLAUDE.user.md is silent; this is not that.
        #expect(!fixture.exists(AgentGuide.userFilename))
        #expect(store.banners.losses.count == 1)
        #expect(store.banners.losses.first?.message == "Renamed 'CLAUDE.md' to 'CLAUDE 2.md' — Lanework needs that name")
    }

    /// The rescue name is checked with `lstat` semantics, so a **broken** symlink counts as taken:
    /// `fileExists` would follow it, find nothing, call the name free — and the move would then fail
    /// into a banner about a file the user never asked for.
    @Test("A dangling symlink on CLAUDE.user.md counts as taken")
    func danglingRescueNameIsTaken() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
        try FileManager.default.createSymbolicLink(atPath: userFileURL(in: fixture).path, withDestinationPath: "nowhere.md")
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data(AgentGuide.filename) == Data("# mine\n".utf8))
        #expect(try FileManager.default.destinationOfSymbolicLink(atPath: userFileURL(in: fixture).path) == "nowhere.md")
        #expect(store.banners.oneShots.isEmpty)
    }

    /// A regular file the app cannot *read* is markerless, not missing — so it is displaced, never
    /// overwritten. The move preserves it without ever needing to read it.
    @Test("An unreadable CLAUDE.md is displaced, not clobbered")
    func unreadableGuideIsDisplaced() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let url = try writeRoot(AgentGuide.filename, Data("# secret\n".utf8), in: fixture)
        try FileManager.default.setAttributes([.posixPermissions: 0o000], ofItemAtPath: url.path)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        try FileManager.default.setAttributes([.posixPermissions: 0o644], ofItemAtPath: userFileURL(in: fixture).path)
        #expect(try fixture.data(AgentGuide.userFilename) == Data("# secret\n".utf8))
    }

    /// **Updated 2026-07-29** — the claimed-name squatter ruling: a folder on the guide's name is an
    /// invalid artifact, not a resident. It moves aside whole, **contents preserved verbatim**, and
    /// the guide takes the freed name.
    @Test("A folder named CLAUDE.md is displaced whole, contents intact")
    func directoryGuideIsDisplaced() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data("CLAUDE 2.md/inside.txt") == Data("inside".utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        // Displacement, never a rescue: `CLAUDE.user.md` is where *user content* goes, and a folder
        // on a file's name is not that.
        #expect(!fixture.exists(AgentGuide.userFilename))
    }

    /// The ladder climbs rather than overwriting: a board that already has a `CLAUDE.md 2` gets a
    /// `CLAUDE.md 3`, Finder-style, one collision at a time.
    @Test("The displacement climbs the Finder ladder past a taken name")
    func displacementClimbsTheLadder() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file("\(AgentGuide.filename)/inside.txt", Data("inside".utf8))
        try writeRoot("CLAUDE 2.md", Data("someone else's\n".utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data("CLAUDE 2.md") == Data("someone else's\n".utf8), "untouched")
        #expect(try fixture.data("CLAUDE 3.md/inside.txt") == Data("inside".utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    /// 02-architecture.md § Write-failure surfacing: "The open-time agent-guide write … is
    /// skipped-with-log, the `CLAUDE.user.md`-taken precedent." A board on a read-only volume must
    /// not spend a banner on a courtesy file.
    @Test("An unwritable board root is skipped silently")
    func unwritableRootIsSkipped() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)
        // Readable (so the board still loads and renders) but not writable.
        try FileManager.default.setAttributes([.posixPermissions: 0o500], ofItemAtPath: fixture.root.path)

        store.refreshAgentGuide()

        #expect(!fixture.exists(AgentGuide.filename))
        #expect(brackets.begins == 0)
        #expect(store.banners.oneShots.isEmpty)
    }

    /// The read-only *lock* defers it as well — the relocation's posture, and 02's.
    @Test("A read-only-locked board defers, and writes the guide when the lock clears")
    func lockedBoardDefers() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.enterUnwritableLock(.permissionDenied)

        store.refreshAgentGuide()
        #expect(!fixture.exists(AgentGuide.filename))

        // A reconciling reload re-probes writability, the lock clears — and the same reload writes
        // the guide it had been holding back.
        store.handleWatcherEvent(.treeChanged(.reconciling))
        await store.awaitQuiescence()

        #expect(!store.isReadOnly)
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    @Test("Refreshing twice writes once")
    func secondRefreshIsANoOp() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()
        let after = try stat(guideURL(in: fixture))
        store.refreshAgentGuide()

        #expect(try stat(guideURL(in: fixture)).modified == after.modified)
        #expect(brackets.begins == 1)
    }

    /// The self-heal, stated where it is deterministic: every successful reload re-checks the guide,
    /// so a foreign deletion or downgrade is repaired by the reload that noticed it.
    @Test("A reload re-checks the guide — a deleted one comes back")
    func aReloadHealsIt() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.refreshAgentGuide()
        try FileManager.default.removeItem(at: guideURL(in: fixture))

        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    /// The memo `refreshAgentGuide()` documents, from both sides: a picture this store has already
    /// refused to act on is not acted on again by every reload that re-reads it, and a picture that
    /// genuinely *changed* is a fresh attempt.
    @Test("A skipped board stays skipped across reloads, and heals when the name frees up")
    func aSkipIsNotRetriedUntilThePictureChanges() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // A *folder* on the rescue name is exactly as taken as a file — the app never descends into
        // one, renames around it, or overwrites it.
        try writeRoot(AgentGuide.filename, Data("# mine\n".utf8), in: fixture)
        try fixture.file("\(AgentGuide.userFilename)/inside.txt", Data("inside".utf8))
        // The twin is already current throughout: this test's whole point is the memo behavior on
        // `CLAUDE.md`'s own skip/rescue arc, so `AGENTS.md` sits out every reload as a quiet
        // `.leaveAlone` — never contributing to the signature, never itself opening a bracket.
        try writeRoot(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()
        for _ in 0 ..< 3 {
            store.handleWatcherEvent(.treeChanged(.foreign))
            await store.awaitQuiescence()
        }

        #expect(brackets.begins == 0, "no write was ever attempted")
        #expect(store.banners.oneShots.isEmpty, "and no row was posted, once or four times")
        #expect(try fixture.data(AgentGuide.filename) == Data("# mine\n".utf8))
        #expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8), "the already-current twin stayed untouched too")
        #expect(try fixture.data("\(AgentGuide.userFilename)/inside.txt") == Data("inside".utf8))

        // The name frees up: a different picture, so a fresh attempt — and the rescue finally runs.
        try FileManager.default.removeItem(at: userFileURL(in: fixture))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(try fixture.data(AgentGuide.userFilename) == Data("# mine\n".utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        #expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8), "still exactly current — this bracket was entirely CLAUDE.md's")
        #expect(brackets.begins == 1)
    }

    @Test("The guide is not a stray, and does not disturb the load")
    func guideIsNotAStray() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.refreshAgentGuide()
        try writeRoot(AgentGuide.userFilename, Data("# mine\n".utf8), in: fixture)

        let result = try BoardLoader.load(boardRoot: fixture.root)
        #expect(result.warnings.isEmpty, "both names are app-claimed, never stray-warned")
        #expect(result.model.lanes.count == 1)
    }
}

// MARK: - 3b. The AGENTS.md twin (ruled 2026-08-09, card dc1314bb)

/// The twin promise, exercised where it costs something: both claimed names get the identical guide,
/// upgrade independently, and heal independently — the "byte-identical, no drift" claim proved on
/// disk rather than merely read off the source.
@MainActor
@Suite("Agent guide ▸ the twin")
struct AgentGuideTwinTests {

    @Test("A board with neither claimed name gets both, byte-identical, in one bracket")
    func freshBoardGetsBothFilesByteIdentical() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let brackets = GuideBracketLog()
        brackets.attach(to: store)

        store.refreshAgentGuide()

        let claude = try fixture.data(AgentGuide.filename)
        let agents = try fixture.data(AgentGuide.agentsFilename)
        #expect(claude == Data(AgentGuide.content.utf8))
        #expect(agents == claude, "byte-identical, not merely equal-content")
        #expect(brackets.begins == 1, "one bracket for both files, one app-mediated reload")
    }

    @Test("Both claimed names at an older version are upgraded to the current one, identically")
    func bothOlderFilesAreUpgradedIdentically() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
        try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: 4).utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        let claude = try fixture.data(AgentGuide.filename)
        let agents = try fixture.data(AgentGuide.agentsFilename)
        #expect(claude == Data(AgentGuide.content.utf8))
        #expect(agents == claude)
    }

    /// The per-file independence the design leans on: one claimed name being current must never gate
    /// the other's write, and vice versa.
    @Test("A stale AGENTS.md is upgraded even while CLAUDE.md is already current")
    func staleAgentsFileUpgradesAloneWhenClaudeIsCurrent() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let claudeURL = try writeRoot(AgentGuide.filename, Data(AgentGuide.content.utf8), in: fixture)
        try writeRoot(AgentGuide.agentsFilename, Data(guideText(version: 4).utf8), in: fixture)
        let claudeBefore = try stat(claudeURL)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        let claudeAfter = try stat(claudeURL)
        #expect(claudeAfter.bytes == claudeBefore.bytes)
        #expect(claudeAfter.modified == claudeBefore.modified, "current guide untouched, even while its twin is rewritten")
        #expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8))
    }

    @Test("A stale CLAUDE.md is upgraded even while AGENTS.md is already current")
    func staleClaudeFileUpgradesAloneWhenAgentsIsCurrent() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try writeRoot(AgentGuide.filename, Data(guideText(version: 4).utf8), in: fixture)
        let agentsURLBefore = try writeRoot(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8), in: fixture)
        let agentsBefore = try stat(agentsURLBefore)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        let agentsAfter = try stat(agentsURLBefore)
        #expect(agentsAfter.bytes == agentsBefore.bytes)
        #expect(agentsAfter.modified == agentsBefore.modified)
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    /// A folder or symlink squatting `AGENTS.md` is displaced exactly as one squatting `CLAUDE.md`
    /// is — the level-uniform claimed-name rule extended to the twin.
    @Test("A folder squatting AGENTS.md is displaced whole, contents intact, and the guide takes its place")
    func squattedAgentsFileIsDisplaced() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file("\(AgentGuide.agentsFilename)/inside.txt", Data("inside".utf8))
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        #expect(try fixture.data("AGENTS 2.md/inside.txt") == Data("inside".utf8))
        #expect(try fixture.data(AgentGuide.agentsFilename) == Data(AgentGuide.content.utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
    }

    /// **The design decision on the shared rescue name**: both claimed names funnel a markerless
    /// foreign file to the same single `CLAUDE.user.md` — there is no second `AGENTS.user.md`. When
    /// both are foreign at once, `filename` (processed first) wins the rescue and `agentsFilename`
    /// finds the name taken and skips — the standing "two files the user owns, both left alone"
    /// contract, just reachable from either starting name.
    @Test("Markerless content on both claimed names at once: the first rescues, the second skips")
    func simultaneousMarkerlessContentOnBothNamesRescuesOnlyTheFirst() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let claudeMine = "# My CLAUDE.md notes\n"
        let agentsMine = "# My AGENTS.md notes\n"
        try writeRoot(AgentGuide.filename, Data(claudeMine.utf8), in: fixture)
        try writeRoot(AgentGuide.agentsFilename, Data(agentsMine.utf8), in: fixture)
        let store = try BoardStore(rootURL: fixture.root)

        store.refreshAgentGuide()

        // `filename` is processed first: its content is rescued to the shared destination, and the
        // guide takes its place.
        #expect(try fixture.data(AgentGuide.userFilename) == Data(claudeMine.utf8))
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        // `agentsFilename` finds `CLAUDE.user.md` taken by the time it's inspected: its own content
        // is left exactly where it was, untouched — never destroyed, never silently adopted either.
        #expect(try fixture.data(AgentGuide.agentsFilename) == Data(agentsMine.utf8))
    }
}

// MARK: - 4. Through the registry, with a real watcher

@MainActor
@Suite("Agent guide ▸ the registry")
struct AgentGuideRegistryTests {

    /// The open-time firing, wired the way the app wires it — after the watcher and the brackets
    /// exist, so the write has a reload behind it.
    @Test("Acquiring a board writes the guide")
    func acquireWritesTheGuide() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let registry = BoardStoreRegistry()

        let store = try registry.acquire(fixture.root)
        defer { registry.release(store) }

        await waitUntil { fixture.exists(AgentGuide.filename) }
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        #expect(store.reloadFailure == nil)
    }

    /// Self-healing, over the real FSEvents path: nothing in this test hands the store an event.
    @Test("A guide deleted under a live board comes back")
    func foreignDeletionHeals() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let registry = BoardStoreRegistry()

        let store = try registry.acquire(fixture.root)
        defer { registry.release(store) }

        await waitUntil { fixture.exists(AgentGuide.filename) }
        await settle()

        try FileManager.default.removeItem(at: guideURL(in: fixture))
        #expect(!fixture.exists(AgentGuide.filename))

        await waitUntil { fixture.exists(AgentGuide.filename) }
        #expect(try fixture.data(AgentGuide.filename) == Data(AgentGuide.content.utf8))
        #expect(store.reloadFailure == nil)
    }
}

// MARK: - 5. The content

/// What the guide *says*, pinned where saying it wrong would mislead every agent that reads it —
/// the authoring card's contract (08-agent-integration.md ▸ The agent guide's list): the schema it
/// condenses is 01-storage-format.md's, post-pivot, and the vocabulary it teaches must be the
/// app's own.
@Suite("Agent guide ▸ the content")
struct AgentGuideContentTests {

    /// The palette tables are transcribed prose, and transcriptions drift. Tying every name to
    /// `Palette`'s own tables makes a palette rename a failing guide test rather than a silently
    /// wrong document teaching agents colors the app no longer resolves.
    @Test("Every palette name the app resolves appears in the guide")
    func paletteNamesMatchTheSource() {
        for color in Palette.foregrounds + Palette.backgrounds {
            #expect(AgentGuide.content.contains("`\(color.name)`"), "missing palette name: \(color.name)")
        }
    }

    @Test("The guide teaches the current conventions by name")
    func currentVocabularyIsPresent() {
        let content = AgentGuide.content
        // The rewrite's list, 08 ▸ The agent guide: attachments, the trash, self-stamping, the
        // user extension point, staging etiquette — each findable by the string an agent would
        // grep for.
        #expect(content.contains(".trash/"))
        #expect(content.contains("attachments/"))
        #expect(content.contains("modified-by"))
        #expect(content.contains("CLAUDE.user.md"))
        #expect(content.contains("git add -A"))
        #expect(content.contains("schema: 1"))
    }

    /// v7's list, 08 ▸ The agent guide's "v7 additionally teaches" bullet: the refined
    /// stamp-discipline predicate (01-storage-format.md ▸ `modified`'s scope, ruled 2026-07-29,
    /// refined 2026-07-30) and the card-level `attachments` claimed name (01-storage-format.md
    /// § Fractal layout ▸ Rules, "level-uniform"). Each pin is a phrase an agent reading the guide
    /// would actually see, not a paraphrase — so a wording rewrite that silently drops the rule
    /// fails here instead of shipping quietly.
    @Test("v7 teaches the stamp-discipline predicate and the attachments claimed name")
    func v7VocabularyIsPresent() {
        let content = AgentGuide.content
        // The predicate itself: one rule, not a trash special case.
        #expect(content.contains("a move that changes an item's container"))
        #expect(content.contains("rewrites only `order`"))
        #expect(content.contains("into/out of `.trash/`"))
        #expect(content.contains("The trash move isn't an exception"))
        // The attachments claimed name: don't squat the folder's name with a file.
        #expect(content.contains("The name `attachments` itself belongs to that folder"))
        #expect(content.contains("*file* called `attachments` in a card"))
    }

    /// **Lanes delete into the trash too** (08 ▸ The agent guide's present-tense list; 03-board-ui.md
    /// § Trash, re-ruled 2026-07-29): the guide has to teach the *move*, the `kind` value that tells a
    /// trashed lane from a card in the flat container, and the stamp on the way in. The shipped v7
    /// literal already says all three — this is the guard that keeps a wording pass from dropping one
    /// silently, which is the failure class the per-version changelog bullets were retired over.
    @Test("The guide teaches lanes-in-trash: the move, the kind value, and the stamp")
    func lanesInTrashVocabularyIsPresent() {
        let content = AgentGuide.content
        // The delete itself is one sentence covering both levels.
        #expect(content.contains("Delete a card or a lane = move its folder into `<board>/.trash/`"))
        #expect(content.contains("for a whole lane (create `.trash/` if missing)"))
        #expect(content.contains("travels with its cards inside it"))
        // `kind` at creation, always — depth says what an item is on the board, but `.trash/` is flat.
        #expect(content.contains("**Always write `kind`** at creation"))
        #expect(content.contains("tells a trashed lane from a card"))
        // And stamped on the way in when it is missing.
        #expect(content.contains("**Stamp `kind: lane` when you trash a lane that lacks it.**"))
        // The one place permanence is named, and it names both kinds.
        #expect(content.contains("the trash is the recoverable path for both"))
    }

    /// **v8: the arrival rank is retired** (08-agent-integration.md's own line, re-ruled 2026-07-31:
    /// "move the card **or lane** folder into `<root>/.trash/` and restamp `modified` (the trash
    /// sorts newest-first by that stamp … no rank to mint)"). The guide has to teach the *stamp* as
    /// the position and to leave `order` alone — and, just as load-bearing, it must no longer teach
    /// the rank formula: an agent still computing "smallest `order` minus 1024" would be writing a
    /// key the app now deliberately preserves for the restore.
    @Test("v8 teaches the stamp as the trash's order, and the rank formula is gone")
    func v8TrashOrderingVocabularyIsPresent() {
        let content = AgentGuide.content
        #expect(content.contains("sorts by `modified`, newest first**"))
        #expect(content.contains("the stamp is also the position"))
        #expect(content.contains("there is no rank to mint"))
        #expect(content.contains("leave `order` exactly as it is"))
        // The retired formula, in the two spellings the v7 literal used.
        #expect(!content.contains("smallest `order` already in `.trash/`"))
        #expect(!content.contains("Arrivals go on top"))
    }

    /// **v9: values stay on one line** (2026-07-31 incident): a wrapped double-quoted `title` lost
    /// its continuation line in a hand copy between boards, leaving the quote unclosed — and an
    /// unclosed quote runs to the end of the block, so the whole file stopped parsing and the board
    /// refused to load. The guide has to teach the shape, say why copying it is where it breaks, and
    /// name the unterminated quote in Hard rules beside the colon it now shares billing with.
    @Test("v9 teaches one-line frontmatter values and names the unterminated quote")
    func v9OneLineValueVocabularyIsPresent() {
        let content = AgentGuide.content
        #expect(content.contains("**Keep every value on one line.**"))
        #expect(content.contains("swallow every key below"))
        #expect(content.contains("copy its frontmatter block whole"))
        // Hard rules names both classic violations, not just the colon.
        #expect(content.contains("The classic violations are an"))
        #expect(content.contains("quoted value left unclosed across a"))
    }

    /// **v10: the zero-read minimum** (01-storage-format.md § Frontmatter and § Ordering, re-ruled
    /// 2026-07-31). 08-agent-integration.md's masterplan requirement — "filing a card must need
    /// nothing but the schema" — was untrue while a card needed a rank, because a rank needed a scan
    /// of every sibling in the lane. The guide has to teach both halves: the short form is legal and
    /// lands at the bottom, and *writing* `order` is still the only way to choose a position.
    @Test("v10 teaches optional keys and the zero-read minimum")
    func v10OptionalKeyVocabularyIsPresent() {
        let content = AgentGuide.content
        // The keys are optional below the root, and the root's `schema` is not.
        #expect(content.contains("**required at the board's own `index.md`**"))
        #expect(content.contains("optional below it — a lane or card without one is read as schema 1"))
        #expect(content.contains("**optional, and the way to control position**"))
        // The minimum card, and where it lands.
        #expect(content.contains("**You can also file a card without reading the lane at all.**"))
        #expect(content.contains("no `order`, no `schema`"))
        #expect(content.contains("It lands at the bottom of the lane"))
        #expect(content.contains("the app\nwrites a real `order` into it"))
        // Reading order names the rule the minimum card depends on.
        #expect(content.contains("An item with no\n  `order` sorts after every item that has one"))
        // Hard rules no longer calls either key required below the root.
        #expect(content.contains("`schema` and `order` are optional and a\n  missing one is read, never refused"))
        #expect(!content.contains("Lanes and cards additionally require `order`"))
        #expect(!content.contains("plus `order` on lanes and"))
    }

    /// The pathfinder's guide taught `media/` and tombstone deletes; both are retired
    /// (01-storage-format.md ▸ Changes from the pathfinder schema; ▸ Deletion). The one legitimate
    /// mention of `deleted:` is the warning never to write it.
    @Test("Retired vocabulary does not resurface")
    func retiredVocabularyIsAbsent() {
        let content = AgentGuide.content
        #expect(!content.contains("media/"))
        #expect(!content.contains("tombstone"))
        #expect(content.contains("Never write a `deleted:` key"))
        // The 2026-07-29 rule was first named "moves-don't-stamp"; the 2026-07-30 refinement
        // retired that framing (container changes stamp, the trash move included) — the guide
        // must never teach the superseded shape of the rule.
        #expect(!content.contains("moves don't stamp"))
        #expect(!content.contains("moves-don't-stamp"))
    }

    /// **v14: the Comments chapter** — mechanics only (01-storage-format.md § Enhanced schema is the
    /// authority; conduct is a separate section, tested below). Each pin is a phrase an agent reading
    /// the guide would actually see.
    @Test("v14 teaches the comment thread's mechanics")
    func v14CommentsVocabularyIsPresent() {
        let content = AgentGuide.content
        #expect(content.contains("## Comments"))
        #expect(content.contains("comments/<lowercase-uuid>/index.md"))
        #expect(content.contains("**No\n  `title`, no `order`.**"))
        #expect(content.contains("**Chronology is the ordering**"))
        #expect(content.contains("**`author` is self-reported and survives app writes**"))
        #expect(content.contains("**`comments/.draft` and `comments/.trash` are the app's**"))
        #expect(content.contains("post a follow-up saying so — never\n  rewrite or remove an existing one"))
        #expect(content.contains("counts as **edited** when `modified` differs from"))
    }

    /// **v14: the conduct chapter** — the guide's first word on *when* to post, not just how
    /// (08-agent-integration.md ▸ Use the thread). Card body vs. thread, START-with-a-plan, questions
    /// as comments, re-read before resuming, verification on close, and the no-stamp guarantee.
    @Test("v14 teaches thread conduct: journal your work")
    func v14ConductVocabularyIsPresent() {
        let content = AgentGuide.content
        #expect(content.contains("## Use the thread: journal your work"))
        #expect(content.contains("The card's body is the spec; its comment thread"))
        #expect(content.contains("**Starting work on a card**"))
        #expect(content.contains("written for a\n  reader with none of your context"))
        #expect(content.contains("**Questions**: post them as comments."))
        #expect(content.contains("**re-read the whole thread before resuming any card**"))
        #expect(content.contains("**Finishing**: close with verification evidence"))
        #expect(content.contains("**Comments never stamp the card**"))
        #expect(content.contains("belongs in `CLAUDE.user.md`, not here."))
    }

    /// **v14: `comments/` leaves the reserved-keys list** — it shipped, so it is no longer a name
    /// agents are told to avoid; the tracker-integration keys are what remains reserved.
    @Test("v14 corrects the reserved-keys list: comments/ is gone, the tracker keys remain")
    func v14ReservedKeysCorrectionIsPresent() {
        let content = AgentGuide.content
        #expect(content.contains("`comments/` is **not** on this list"))
        #expect(content.contains("`labels`,\n`assignees`, `due`, the `remote` key (cards and board), and `remote-state`"))
        #expect(!content.contains("and a card-level `comments/` folder"))
    }

    /// **v14: the `AGENTS.md` twin** — named in the marker line, the Layout diagram, and the Git
    /// section's "leave the app-maintained files alone" bullet, so an agent reading either copy of
    /// the guide learns the other exists.
    @Test("v14 names the AGENTS.md twin in the marker, the layout, and Git")
    func v14TwinVocabularyIsPresent() {
        let content = AgentGuide.content
        #expect(content.contains("This guide is written at two names, CLAUDE.md and AGENTS.md, kept byte-identical."))
        #expect(content.contains("AGENTS.md                byte-identical twin of CLAUDE.md (app-maintained)"))
        #expect(content.contains("comments/        the card's comment thread (see Comments)"))
        #expect(content.contains("this guide (both\n  `CLAUDE.md` and its `AGENTS.md` twin) and the seeded `.gitignore`"))
    }
}
