import Foundation
import Testing
@testable import Kanban

/// The open-time half of the orphan tidy — `BoardStore.tidyBackgroundImages` (01-storage-format.md §
/// Validation and healing, ruled 2026-08-09: "any file in `.backgrounds/` not referenced by the
/// board's current `background.image` is swept"). Its sibling, the replace-in-place trim a background
/// apply makes on its own prior file, is pinned in `GeneratedBackgroundTests.swift`
/// (`BackgroundReplaceInPlaceTrimTests`); this file is the safety net behind it — the scheduled heal
/// that catches whatever the trim declines (the echo window, a foreign repoint, a trim that failed and
/// was swallowed) on the next open.
///
/// Like the loose-file relocation suite this one is modeled on (`LooseFileRelocationTests.swift`), the
/// board here carries a **current agent guide and a seeded `.gitignore`** — what any board the app has
/// opened once looks like — so the store's *other* scheduled heals do not open brackets or write files
/// of their own and confuse the bracket counts and banner rows these tests read.

// MARK: - Fixtures

private func makeBoard(background: String? = nil) throws -> WriterFixture {
    let fixture = try WriterFixture()
    let line = background.map { "background: \($0)\n" } ?? ""
    try fixture.item("", "---\nschema: 1\ntitle: Work\n\(line)---\nBoard description.\n")
    try fixture.file(AgentGuide.filename, Data(AgentGuide.content.utf8))
    try fixture.file(AgentGuide.agentsFilename, Data(AgentGuide.content.utf8))
    try fixture.file(IntegrityRules.gitignoreFileName, Data(BoardWriter.gitignoreSeed.utf8))
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    return fixture
}

@MainActor
private func makeStore(_ fixture: WriterFixture) throws -> BoardStore {
    try BoardStore(rootURL: fixture.root)
}

/// Counts the bracket calls a store makes — `LooseFileRelocationTests.RelocationBracketLog`'s twin,
/// redeclared here since that one is private to its own file.
@MainActor
private final class TidyBracketLog {
    private(set) var begins = 0

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

// MARK: - The sweep

@MainActor
@Suite("Background images ▸ the open-time sweep")
struct BackgroundImageSweepTests {

    @Test("An unreferenced .backgrounds/ file is removed and the referenced one is spared")
    func removesUnreferencedAndSparesReferenced() throws {
        let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data("current".utf8))
        try fixture.file(".backgrounds/Pasted Background.png", Data("orphan".utf8))
        let store = try makeStore(fixture)

        store.tidyBackgroundImages()

        #expect(fixture.exists(".backgrounds/facets.png"), "still named by background.image")
        #expect(try fixture.data(".backgrounds/facets.png") == Data("current".utf8))
        #expect(!fixture.exists(".backgrounds/Pasted Background.png"), "nothing named it any more")
        #expect(store.banners.losses.map(\.message)
            == ["Removed 'Pasted Background.png' — it was no longer the board's background"])
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A board with no background field sweeps everything .backgrounds/ holds")
    func noReferenceSweepsEverything() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data("a".utf8))
        try fixture.file(".backgrounds/Pasted Background.png", Data("b".utf8))
        let store = try makeStore(fixture)

        store.tidyBackgroundImages()

        #expect(try fixture.entryNames(".backgrounds").isEmpty)
        #expect(store.banners.losses.map(\.message) == ["Removed 2 background images — they were no longer the board's background"])
    }

    /// **Legacy scope, pinned at the sweep too**: a bare root-level reference is never even a
    /// candidate — the sweep only ever looks inside `.backgrounds/` — so it survives untouched
    /// whether or not it happens to share a name with something the sweep does remove.
    @Test("A legacy root-level file is never swept, referenced or not")
    func legacyRootLevelFileIsNeverSwept() throws {
        let fixture = try makeBoard(background: "{image: facets.png}")
        defer { fixture.tearDown() }
        try fixture.file("facets.png", Data("legacy".utf8))
        try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
        let store = try makeStore(fixture)

        store.tidyBackgroundImages()

        #expect(try fixture.data("facets.png") == Data("legacy".utf8), "outside .backgrounds/ — never a candidate")
        #expect(!fixture.exists(".backgrounds/facets.png"), "unreferenced — the legacy spelling does not protect it")
    }

    @Test("A missing .backgrounds/ folder is an ordinary resting state")
    func missingFolderIsResting() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try makeStore(fixture)
        let brackets = TidyBracketLog()
        brackets.attach(to: store)

        store.tidyBackgroundImages()

        #expect(brackets.begins == 0)
        #expect(store.banners.losses.isEmpty)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A board with nothing orphaned writes nothing and says nothing")
    func nothingOrphanedIsSilent() throws {
        let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data("current".utf8))
        let store = try makeStore(fixture)
        let brackets = TidyBracketLog()
        brackets.attach(to: store)

        store.tidyBackgroundImages()

        #expect(brackets.begins == 0)
        #expect(store.banners.losses.isEmpty)
        #expect(fixture.exists(".backgrounds/facets.png"))
    }

    /// `.skipsHiddenFiles` doing its job: the app's own crashed-write residue and Finder's litter are
    /// never proposed as candidates, exactly as `BoardLoader.directoryCandidates` already treats them
    /// everywhere else.
    @Test("Hidden entries inside .backgrounds/ are never candidates")
    func hiddenEntriesAreNeverCandidates() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/.DS_Store", Data("finder".utf8))
        try fixture.file(".backgrounds/.facets.png.lanework-1234", Data("residue".utf8))
        let store = try makeStore(fixture)
        let brackets = TidyBracketLog()
        brackets.attach(to: store)

        store.tidyBackgroundImages()

        #expect(brackets.begins == 0)
        #expect(fixture.exists(".backgrounds/.DS_Store"))
        #expect(fixture.exists(".backgrounds/.facets.png.lanework-1234"))
    }

    /// A sub-folder of `.backgrounds/` is content the sweep leaves alone — the same "only this level's
    /// own contents" scope `relocateLooseFiles` gives a card's `attachments/`.
    @Test("A sub-folder inside .backgrounds/ is not a candidate")
    func subfoldersAreNotCandidates() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/art/sunset.png", Data("nested".utf8))
        let store = try makeStore(fixture)

        store.tidyBackgroundImages()

        #expect(fixture.exists(".backgrounds/art/sunset.png"))
        #expect(store.banners.losses.isEmpty)
    }

    /// A locked board defers — the loose-file relocation heal's own posture, restated: strays (here,
    /// orphans) stay tolerated until the lock clears.
    @Test("A read-only board sweeps nothing, and sweeps once the lock clears")
    func readOnlyBoardDefers() async throws {
        let fixture = try makeBoard(background: "{image: .backgrounds/facets.png}")
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data("current".utf8))
        try fixture.file(".backgrounds/Pasted Background.png", Data("orphan".utf8))
        let store = try makeStore(fixture)
        let brackets = TidyBracketLog()
        brackets.attach(to: store)
        store.enterUnwritableLock(.permissionDenied)

        store.tidyBackgroundImages()

        #expect(fixture.exists(".backgrounds/Pasted Background.png"), "tolerated under the lock")
        #expect(store.banners.losses.isEmpty)
        #expect(brackets.begins == 0)
        #expect(store.isReadOnly)

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

        #expect(!store.isReadOnly)
        #expect(!fixture.exists(".backgrounds/Pasted Background.png"))
        #expect(store.banners.losses.map(\.message)
            == ["Removed 'Pasted Background.png' — it was no longer the board's background"])
    }

    /// The loop the guard exists for: a sweep that fails leaves the same file on disk, so the next
    /// walk hands back the same work — one failure, one row, then silence.
    @Test("A failing sweep is attempted once, not forever")
    func repeatedFailureDoesNotHotLoop() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
        let orphan = fixture.root.appendingPathComponent(".backgrounds/facets.png")
        try FileManager.default.setAttributes([.immutable: true], ofItemAtPath: orphan.path)
        defer { try? FileManager.default.setAttributes([.immutable: false], ofItemAtPath: orphan.path) }
        let store = try makeStore(fixture)
        let brackets = TidyBracketLog()
        brackets.attach(to: store)

        store.tidyBackgroundImages()
        #expect(store.banners.oneShots.count == 1)
        #expect(store.banners.oneShots.first?.error.operation == .tidyBackgroundImage(filename: "facets.png"))
        #expect(store.banners.losses.isEmpty)
        #expect(brackets.begins == 1)

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

        // Same picture on disk, so no second attempt and no second row.
        #expect(store.banners.oneShots.count == 1)
        #expect(brackets.begins == 1)

        // A picture that actually changed is a fresh attempt.
        try FileManager.default.setAttributes([.immutable: false], ofItemAtPath: orphan.path)
        try fixture.file(".backgrounds/second.png", Data("also orphan".utf8))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        #expect(brackets.begins == 2)
        #expect(!fixture.exists(".backgrounds/facets.png"))
        #expect(!fixture.exists(".backgrounds/second.png"))
    }

    @Test("runScheduledHeals reaches the sweep")
    func runScheduledHealsReachesTheSweep() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data("orphan".utf8))
        let store = try makeStore(fixture)

        store.runScheduledHeals()

        #expect(!fixture.exists(".backgrounds/facets.png"))
        #expect(store.banners.losses.map(\.message)
            == ["Removed 'facets.png' — it was no longer the board's background"])
    }
}

// MARK: - The candidate listing (pure)

@Suite("Background images ▸ orphaned-file listing")
struct OrphanedBackgroundFileNamesTests {

    private func fixture() throws -> WriterFixture { try WriterFixture() }

    @Test("A missing folder lists as empty")
    func missingFolderListsEmpty() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }

        #expect(BoardStore.orphanedBackgroundFileNames(
            inFolder: fixture.url(".backgrounds"), keeping: nil
        ) == [])
    }

    @Test("Every file lists when nothing is kept, sorted")
    func everyFileListsWhenNothingIsKept() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/b.png", Data())
        try fixture.file(".backgrounds/a.png", Data())

        #expect(BoardStore.orphanedBackgroundFileNames(
            inFolder: fixture.url(".backgrounds"), keeping: nil
        ) == ["a.png", "b.png"])
    }

    @Test("The kept name is excluded, everything else lists")
    func theKeptNameIsExcluded() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data())
        try fixture.file(".backgrounds/Pasted Background.png", Data())

        #expect(BoardStore.orphanedBackgroundFileNames(
            inFolder: fixture.url(".backgrounds"), keeping: "facets.png"
        ) == ["Pasted Background.png"])
    }

    @Test("Hidden entries and sub-folders are excluded")
    func hiddenAndNestedAreExcluded() throws {
        let fixture = try fixture()
        defer { fixture.tearDown() }
        try fixture.file(".backgrounds/facets.png", Data())
        try fixture.file(".backgrounds/.DS_Store", Data())
        try fixture.file(".backgrounds/art/sunset.png", Data())

        #expect(BoardStore.orphanedBackgroundFileNames(
            inFolder: fixture.url(".backgrounds"), keeping: nil
        ) == ["facets.png"])
    }
}

// MARK: - qualifiedBareName (pure)

@Suite("Background images ▸ qualifiedBareName")
struct QualifiedBareNameTests {

    @Test("A qualified reference answers its bare name")
    func qualifiedAnswersBareName() {
        #expect(BoardBackdrop.qualifiedBareName(of: ".backgrounds/facets.png") == "facets.png")
        #expect(BoardBackdrop.qualifiedBareName(of: ".backgrounds/Pasted Background.png") == "Pasted Background.png")
    }

    @Test("Everything that is not a direct .backgrounds/ child answers nil", arguments: [
        nil, "facets.png", "art/sunset.jpg", ".backgrounds/art/sunset.png", ".backgrounds/", ".backgrounds",
        "other/.backgrounds/facets.png",
    ] as [String?])
    func everythingElseAnswersNil(_ reference: String?) {
        #expect(BoardBackdrop.qualifiedBareName(of: reference) == nil)
    }
}

// MARK: - Phrasing (BannerCenter owns every word)

@Suite("Background images ▸ phrasing")
struct BackgroundTidyMessageTests {

    @Test("One file names it — the relocation family's own sole-item shape")
    func oneFile() {
        #expect(BannerCenter.tidiedBackgroundImagesMessage(for: ["facets 2.png"])
            == "Removed 'facets 2.png' — it was no longer the board's background")
    }

    @Test("Several files fold to a count")
    func severalFiles() {
        #expect(BannerCenter.tidiedBackgroundImagesMessage(for: ["a.png", "b.png", "c.png"])
            == "Removed 3 background images — they were no longer the board's background")
    }

    @Test("Nothing removed says nothing")
    func nothingRemoved() {
        #expect(BannerCenter.tidiedBackgroundImagesMessage(for: []) == nil)
    }

    /// A failed sweep is a one-shot write failure, and the banner owns its words too — the successful
    /// notice's verb, negated, `.relocateLooseFile`'s own precedent.
    @Test("A failed removal says so in the sweep's own verb")
    func failureHeadline() {
        let error = BoardWriteError(
            operation: .tidyBackgroundImage(filename: "facets.png"),
            path: "/tmp/board/.backgrounds/facets.png",
            reason: .io(message: "disk full")
        )
        #expect(BannerCenter.headline(for: error) == "Couldn't remove 'facets.png' — disk full")
    }
}
