import Foundation
import Testing
@testable import Kanban

/// **The scheduled-heal engine** (02-architecture.md ▸ Components ▸ HealScheduler;
/// 01-storage-format.md § Validation and healing, settled 2026-07-29) — the six-step pattern the
/// three healers used to re-derive one by one, pinned once.
///
/// Each healer's own end-to-end behavior stays where it always lived (`LooseFileRelocationTests`,
/// `TrashWriteTests`, `AgentGuideTests`); what is here is the pattern itself, plus the two places the
/// generalization *changed* behavior on purpose — the writability gate, which was only the guide's,
/// and the explicit clear-on-success, which was only the guide's too.

// MARK: - Fixtures

/// A board whose one card has a loose file — the shape that gives the engine real work through the
/// simplest healer.
@MainActor
private func makeLooseFileBoard() throws -> (fixture: WriterFixture, cardPath: String) {
    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: "Fix login"))
    return (fixture, "\(Ident.lane1)/\(Ident.card1)")
}

@MainActor
private final class BracketLog {
    private(set) var begins = 0

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

// MARK: - The gates

@MainActor
@Suite("HealScheduler ▸ the gates")
struct HealSchedulerGateTests {

    /// **Step 1** — no work is not a failed attempt: an empty signature clears the memo, which is
    /// what makes a healthy board forget it ever tried.
    @Test("No work rests, and clears the memo")
    func noWorkRests() throws {
        let board = try makeLooseFileBoard()
        defer { board.fixture.tearDown() }
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        let store = try BoardStore(rootURL: board.fixture.root)

        store.relocateLooseCardFiles()
        #expect(store.heals.memo(for: .looseCardFiles) == nil, "cleared on success")

        // Nothing loose left in the snapshot's own reading — the resting path.
        store.relocateLooseCardFiles()
        #expect(store.heals.memo(for: .looseCardFiles) == nil)
    }

    /// **Step 2, the lock half** — defer, never abandon, and remember nothing, so the reload that
    /// lifts the lock is the reload that heals.
    @Test("A read-only board defers and remembers nothing")
    func lockDefersWithoutArming() throws {
        let board = try makeLooseFileBoard()
        defer { board.fixture.tearDown() }
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        let store = try BoardStore(rootURL: board.fixture.root)
        let brackets = BracketLog()
        brackets.attach(to: store)
        store.enterUnwritableLock(.permissionDenied)

        store.relocateLooseCardFiles()

        #expect(brackets.begins == 0)
        #expect(store.heals.memo(for: .looseCardFiles) == nil, "a refused attempt is not a remembered one")
        #expect(board.fixture.exists("\(board.cardPath)/notes.txt"))
    }

    /// **Step 2, the writability half — generalized 2026-07-29.** This `access(2)` check was the
    /// agent guide's private defense; it now covers every healer, because it is the only gate that
    /// covers the window *between* writability probes. Without it a heal on a root that went
    /// read-only mid-session reaches the Writer, fails, and banners about work the user never asked
    /// for.
    @Test("An unwritable board root is skipped silently, for every healer")
    func unwritableRootIsSkippedSilently() throws {
        let board = try makeLooseFileBoard()
        defer { board.fixture.tearDown() }
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        let store = try BoardStore(rootURL: board.fixture.root)
        let brackets = BracketLog()
        brackets.attach(to: store)
        // Read-only root, with no lock standing — the mid-session window the probe cannot see.
        try FileManager.default.setAttributes(
            [.posixPermissions: 0o500],
            ofItemAtPath: board.fixture.root.path
        )
        defer {
            try? FileManager.default.setAttributes(
                [.posixPermissions: 0o755],
                ofItemAtPath: board.fixture.root.path
            )
        }

        store.runScheduledHeals()

        #expect(brackets.begins == 0, "no write was attempted at all")
        #expect(store.banners.oneShots.isEmpty, "and nothing was said about it")
        #expect(store.banners.losses.isEmpty)
        #expect(store.heals.memo(for: .looseCardFiles) == nil)
    }

    /// **Steps 3 and 4** — armed before the attempt, so a failure is remembered; one failure, one
    /// row, then silence until the picture on disk actually changes.
    @Test("A failure arms the memo and is not retried")
    func failureArmsTheMemo() throws {
        let board = try makeLooseFileBoard()
        defer { board.fixture.tearDown() }
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        let store = try BoardStore(rootURL: board.fixture.root)
        let brackets = BracketLog()
        brackets.attach(to: store)
        // The card folder is writable but its `attachments/` cannot be created: the relocation's
        // write half fails, with the defect still on disk afterwards.
        try FileManager.default.setAttributes(
            [.posixPermissions: 0o500],
            ofItemAtPath: board.fixture.url(board.cardPath).path
        )
        defer {
            try? FileManager.default.setAttributes(
                [.posixPermissions: 0o755],
                ofItemAtPath: board.fixture.url(board.cardPath).path
            )
        }

        store.relocateLooseCardFiles()
        let armed = store.heals.memo(for: .looseCardFiles)
        store.relocateLooseCardFiles()

        #expect(armed != nil, "armed before the attempt, so the throw leaves it set")
        #expect(store.heals.memo(for: .looseCardFiles) == armed)
        #expect(brackets.begins == 1, "the second call was refused by the memo, not attempted")
        #expect(store.banners.oneShots.count == 1, "one failure, one row")
    }

    /// **Step 6, generalized 2026-07-29** — the memo clears *explicitly* on success, where before
    /// only the guide did this and the others merely happened to converge because the next walk
    /// found no work. It matters when the same defect comes back: a foreign undo restores the exact
    /// picture the heal just fixed, and a standing memo would make that the one thing the self-heal
    /// could not heal.
    @Test("The same picture heals again after a success")
    func theSamePictureHealsAgain() throws {
        let board = try makeLooseFileBoard()
        defer { board.fixture.tearDown() }
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        let store = try BoardStore(rootURL: board.fixture.root)

        store.relocateLooseCardFiles()
        #expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
        #expect(store.heals.memo(for: .looseCardFiles) == nil)

        // Somebody puts it back, byte for byte — the same defect signature as before.
        try FileManager.default.removeItem(at: board.fixture.url("\(board.cardPath)/attachments"))
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        store.relocateLooseCardFiles()

        #expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
    }

    /// The memos are per class, so one heal's failure never silences another's work.
    @Test("Memos are per defect class")
    func memosArePerClass() throws {
        let board = try makeLooseFileBoard()
        defer { board.fixture.tearDown() }
        try board.fixture.file("\(board.cardPath)/notes.txt", Data("notes".utf8))
        let store = try BoardStore(rootURL: board.fixture.root)

        store.runScheduledHeals()

        // The guide wrote (its class rests cleared), the relocation ran (so does its own), and
        // neither class's memo is holding the other's picture.
        #expect(store.heals.memo(for: .looseCardFiles) == nil)
        #expect(store.heals.memo(for: .staleAgentGuide) == nil)
        #expect(store.heals.memo(for: .legacyTombstone) == nil)
        #expect(try board.fixture.data("\(board.cardPath)/attachments/notes.txt") == Data("notes".utf8))
        #expect(board.fixture.exists(AgentGuide.filename))
    }
}

// MARK: - The inline renumber-and-retry

@Suite("HealScheduler ▸ the renumber two-step")
struct HealSchedulerRenumberTests {

    /// A ladder with room answers on the **first** ask — no compaction, and the ladder comes back
    /// exactly as it went in.
    @Test("A usable ladder never renumbers")
    func usableLadderNeverRenumbers() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        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: "A"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "B"))
        let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")

        let placed = try #require(try HealScheduler.placingRanks(
            amongVisible: [1024, 2048],
            compacting: fixture.url(Ident.lane1),
            { Ranks.insertionRank(amongVisible: $0, at: 1) }
        ))

        #expect(placed.placement == 1536)
        #expect(placed.ladder == [1024, 2048])
        #expect(!placed.renumbered)
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)") == before, "nothing was rewritten")
    }

    /// Exhausted precision compacts the parent's visible children and asks again — **against the
    /// fresh ladder**, which is what the caller then has to place among.
    @Test("Exhausted precision compacts and answers against the fresh ladder")
    func exhaustedPrecisionCompacts() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
        // Adjacent doubles: no representable midpoint between them.
        try fixture.item("\(Ident.lane1)/\(Ident.card1)", Item.rich(order: "1", title: "A"))
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "1.0000000000000002", title: "B"))

        let orders = try [Ident.card1, Ident.card2].map {
            try #require(FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order.value)
        }
        let placed = try #require(try HealScheduler.placingRanks(
            amongVisible: orders,
            compacting: fixture.url(Ident.lane1),
            { Ranks.insertionRank(amongVisible: $0, at: 1) }
        ))

        #expect(placed.renumbered)
        #expect(placed.ladder == [1024, 2048])
        #expect(placed.placement == 1536)
        // The compaction really landed on disk — the two cards now hold the fresh ladder.
        let after = try [Ident.card1, Ident.card2].map {
            try FrontmatterDocument.parse(fixture.indexText("\(Ident.lane1)/\($0)")).order
        }
        #expect(after == [.valid(1024), .valid(2048)])
    }

    /// A compacted ladder that still has no answer is "write nothing" — every call site's own
    /// posture, spelled once.
    @Test("No answer after compacting is nil")
    func noAnswerAfterCompactingIsNil() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))

        let placed = try HealScheduler.placingRanks(
            amongVisible: [],
            compacting: fixture.url(Ident.lane1),
            { (_: [Double]) -> Double? in nil }
        )

        #expect(placed == nil)
    }
}
