import Foundation
import Testing
@testable import Kanban

/// `BoardStore.applyStyle` — the one commit point every style anchor shares (03-board-ui.md §
/// Styling ▸ Controls).
///
/// Like `LaneWidthWriteTests` and `InlineEditWriteTests`, these drive a real store over a real temp
/// board and read the **raw bytes** back rather than the app's own read path: the claims are about
/// the file — which key lands or leaves, what the stamps do, and everything else surviving
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

// MARK: - Fixtures

/// An item index carrying whatever style keys a test needs, plus the usual unowned baggage: an
/// unknown key with an inline comment, a `created` from before today, a foreign `modified-by`, and a
/// body — all of which a style write has to leave exactly as it found them.
private func styled(order: String, title: String, keys: [String] = []) -> String {
    let extra = keys.map { "\($0)\n" }.joined()
    return """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    \(extra)project: lanework       # agent overlay
    created: 2026-01-01T09:00:00Z
    modified: 2026-02-02T09:00:00Z
    modified-by: claude
    ---
    \(title) body.

    """
}

/// The board root's own `index.md` — styled like any other item (`StyleTarget.board`), and carrying
/// an `iconColor` a gesture that never names that dimension must leave alone (it stopped being
/// hand-written-only when the board popover's symbol picker grew its colour row, 2026-08-07).
private let boardIndex = """
---
schema: 1
title: Board
iconColor: carnation
---
Board description.

"""

/// Two live lanes with cards, an uneditable lane, and a tombstoned lane with a live card inside it —
/// the ancestor-walk case.
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", boardIndex)
    try fixture.item(Ident.lane1, styled(order: "1024", title: "Todo"))
    try fixture.item("\(Ident.lane1)/\(Ident.card1)", styled(order: "1024", title: "First", keys: ["background: {color: fern}", "iconColor: chalk"]))
    try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second"))
    try fixture.item(Ident.lane2, styled(order: "2048", title: "Doing", keys: ["background: {color: chalk}", "icon: tray"]))
    try fixture.item("\(Ident.lane2)/\(Ident.card3)", styled(order: "1024", title: "Third"))
    try fixture.item(Ident.lane3, Item.uneditable)
    try fixture.item(Ident.lane4, styled(order: "4096", title: "Gone"))
    try fixture.item("\(Ident.lane4)/\(Ident.card4)", styled(order: "1024", title: "Hidden"))
    return fixture
}

private let lane1 = ItemID(rawValue: Ident.lane1)
private let lane2 = ItemID(rawValue: Ident.lane2)
private let lane3 = ItemID(rawValue: Ident.lane3)
private let lane4 = ItemID(rawValue: Ident.lane4)
private let card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)
private let card4 = ItemID(rawValue: Ident.card4)

/// The file's lines minus the ones a style write is *supposed* to change. `iconColor:` deliberately
/// survives the filter — it is not `icon:`, and the app offers no control for it.
private func untouchedLines(_ text: String) -> [Substring] {
    text.split(separator: "\n", omittingEmptySubsequences: false).filter {
        // `kind:` — the on-touch backfill rides every app rewrite of a file that lacks one.
        !$0.hasPrefix("modified") && !$0.hasPrefix("background:") && !$0.hasPrefix("icon:")
            && !$0.hasPrefix("kind:")
    }
}

/// Counts the bracket calls a store makes, standing in for the watcher the registry wires up — the
/// only way to assert "one gesture, one commit" from outside.
@MainActor
private final class BracketLog {
    private(set) var begins = 0
    private(set) var ends = 0

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

private func load(_ fixture: WriterFixture) throws -> BoardModel {
    try BoardLoader.load(boardRoot: fixture.root).model
}

private func card(_ id: ItemID, in model: BoardModel) -> Card? {
    model.lanes.flatMap(\.cards).first { $0.id == id }
}

private func lane(_ id: ItemID, in model: BoardModel) -> Lane? {
    model.lanes.first { $0.id == id }
}

// MARK: - Tests

@MainActor
@Suite("BoardStore ▸ applyStyle")
struct StyleWriteTests {

    @Test("Setting a background writes exactly that key, stamps, and touches nothing else")
    func setsTheBackgroundKey() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")

        store.applyStyle(to: .items([card2]), background: .set("smokey-ocean"))

        let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
        #expect(after.contains("background: {color: \"smokey-ocean\"}"))
        #expect(!after.contains("icon:"), "the untouched dimension writes no key at all")
        #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
        #expect(untouchedLines(after) == untouchedLines(before))

        let written = try #require(card(card2, in: load(fixture)))
        #expect(written.background == .valid("smokey-ocean"))
        let modified = try #require(written.modified.value)
        #expect(abs(modified.timeIntervalSinceNow) < 60)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("Both dimensions land in one rewrite, and iconColor is never touched")
    func setsBothDimensionsAtOnce() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.applyStyle(to: .items([card1]), background: .set("dark-teal"), icon: .set("flag"))

        let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
        #expect(after.contains("background: {color: \"dark-teal\"}"))
        #expect(after.contains("icon: flag"))
        // The third dimension is a control now (the symbol picker's colour row), but a gesture
        // that never names it must still carry it through untouched — per-dimension `.keep`.
        #expect(after.contains("iconColor: chalk"))
        #expect(!after.contains("fern"), "the old value is replaced, not duplicated")
    }

    @Test("The colour row's dimension: iconColor sets as a plain scalar, removes as an absent key, and no-ops in place")
    func iconColorIsTheThirdDimension() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let log = BracketLog()
        log.attach(to: store)

        // The fixture's board already carries `iconColor: carnation` — re-choosing it is a no-op
        // against the loaded snapshot, `skipsNoOps`'s rule at the third dimension.
        store.applyStyle(to: .board, iconColor: .set("carnation"))
        #expect(log.begins == 0, "re-choosing the tint an item already carries opens no bracket")

        store.applyStyle(to: .board, iconColor: .set("fern"))
        let set = try fixture.indexText("")
        #expect(set.contains("iconColor: fern"))
        #expect(!set.contains("carnation"), "the old tint is replaced, not duplicated")
        #expect(!set.contains("icon: "), "the untouched icon dimension writes no key of its own")

        store.applyStyle(to: .board, iconColor: .remove)
        let removed = try fixture.indexText("")
        #expect(!removed.contains("iconColor"))
        #expect(!removed.contains("\"\""), "a removal is a missing key, never an empty string")
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("The None and default wells remove their key rather than writing a blank value")
    func removesTheKey() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.applyStyle(to: .items([lane2]), background: .remove, icon: .remove)

        let after = try fixture.indexText(Ident.lane2)
        #expect(!after.contains("background"))
        #expect(!after.contains("icon"))
        #expect(!after.contains("\"\""), "a removal is a missing key, never an empty string")

        let written = try #require(lane(lane2, in: load(fixture)))
        #expect(written.background.isMissing)
        #expect(written.icon.isMissing)
    }

    @Test("A batch rewrites every target inside a single bracket")
    func batchesInOneBracket() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let log = BracketLog()
        log.attach(to: store)

        // "Choosing a well applies to the whole selection — one gesture, one write" (03 § Styling ▸
        // Controls): the churn has to round back as ONE app-mediated reload.
        store.applyStyle(to: .items([card1, card2]), background: .set("light-cayenne"))

        #expect(log.begins == 1)
        #expect(log.ends == 1)
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card1)").contains("background: {color: \"light-cayenne\"}"))
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"light-cayenne\"}"))
    }

    @Test("A value a target already carries writes nothing — per target and per dimension")
    func skipsNoOps() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let log = BracketLog()
        log.attach(to: store)
        let untouchedCard = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")

        // `card1` is already `{color: fern}` and `card2` has no background at all: only the second file may
        // move. A well clicked twice must not stamp `modified` on what was already right
        // (`setLaneWidth`'s rule).
        store.applyStyle(to: .items([card1, card2]), background: .set("fern"))

        #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == untouchedCard)
        #expect(try fixture.indexText("\(Ident.lane1)/\(Ident.card2)").contains("background: {color: \"fern\"}"))
        #expect(log.begins == 1, "the batch still opens exactly one bracket for the target that moved")
    }

    @Test("A gesture that changes nothing anywhere opens no bracket at all")
    func wholeGestureNoOpWritesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let log = BracketLog()
        log.attach(to: store)
        let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")

        // Both dimensions already read this way: the colour is already `fern` and `icon` is absent, so
        // the removal is a no-op too.
        store.applyStyle(to: .items([card1]), background: .set("fern"), icon: .remove)

        #expect(log.begins == 0)
        #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
        #expect(try fixture.entryNames("\(Ident.lane1)/\(Ident.card1)") == ["index.md"], "no temp-file residue either")
    }

    @Test("A malformed value is replaced — choosing a well always wins")
    func replacesAMalformedValue() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item("\(Ident.lane1)/\(Ident.card2)", styled(order: "2048", title: "Second", keys: ["background: [a, b]"]))
        let store = try BoardStore(rootURL: fixture.root)
        #expect(card(card2, in: store.snapshot)?.background == .malformed(raw: "[a, b]"))

        store.applyStyle(to: .items([card2]), background: .set("shale"))

        let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card2)")
        #expect(after.contains("background: {color: \"shale\"}"))
        #expect(!after.contains("[a, b]"))
    }

    @Test("Board styling writes the board root's own index.md")
    func stylesTheBoardRoot() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.applyStyle(to: .board, background: .set("intense-cool-shale"), icon: .set("square.stack"))

        let after = try fixture.indexText("")
        #expect(after.contains("background: {color: \"intense-cool-shale\"}"))
        #expect(after.contains("icon: square.stack"))
        #expect(after.contains("iconColor: carnation"))
        #expect(after.contains("Board description."))

        let model = try load(fixture)
        #expect(model.background == .valid("intense-cool-shale"))
        // The lanes are none of a board-level gesture's business.
        #expect(lane(lane1, in: model)?.background.isMissing == true)
    }

    /// **The mapping form, through the real writer** (03-board-ui.md § Styling ▸ Capabilities; the
    /// unit-level claims are `BackgroundWriteTests`'). The app has a control for the colour and none
    /// for the image, so the whole gesture — well, then None — has to leave the image standing.
    @Test("A colour change on a board carrying an image preserves the image, and None drops only the colour")
    func preservesABackgroundImage() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", """
            ---
            schema: 1
            title: Board
            background: {color: fern, image: art/sunset.jpg}
            ---
            Board description.

            """)
        let store = try BoardStore(rootURL: fixture.root)

        store.applyStyle(to: .board, background: .set("dark-teal"))

        var model = try load(fixture)
        #expect(model.background == .valid("dark-teal"))
        #expect(model.backgroundImage == .valid("art/sunset.jpg"))
        #expect(try fixture.indexText("").contains("Board description."))

        store.applyStyle(to: .board, background: .remove)

        model = try load(fixture)
        #expect(model.background.isMissing)
        #expect(model.backgroundImage == .valid("art/sunset.jpg"), "the None well removes the colour, not the picture")
    }

    @Test("Vanished and trashed targets are skipped silently")
    func skipsTargetsThatRenderNowhere() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.move("\(Ident.lane2)/\(Ident.card3)", toTrash: Ident.card3)
        try FileManager.default.removeItem(at: fixture.url(Ident.lane4))
        let store = try BoardStore(rootURL: fixture.root)
        let log = BracketLog()
        log.attach(to: store)
        let trashedBytes = try fixture.indexData(".trash/\(Ident.card3)")

        // An id that names nothing, and a card that has been moved to the trash — "everything
        // edit-shaped is disabled on trash selections" (04 ▸ The trash), and "nothing is ever
        // written into a vanished folder".
        store.applyStyle(
            to: .items([ItemID(rawValue: Ident.indexless), card3, card4]),
            background: .set("obsidian")
        )

        #expect(log.begins == 0)
        #expect(try fixture.indexData(".trash/\(Ident.card3)") == trashedBytes)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A readable-but-uneditable target refuses the write, banners it, and keeps its bytes")
    func uneditableTargetBanners() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(Ident.lane3)

        store.applyStyle(to: .items([lane3]), background: .set("fern"))

        #expect(try fixture.indexData(Ident.lane3) == before)
        #expect(store.banners.oneShots.count == 1)
        let posted = try #require(store.banners.oneShots.first)
        #expect(posted.error.operation == .style(title: "Odd"),
                "the title is enriched off the document the write refused")
        #expect(posted.error.reason == .uneditableFrontmatter(.keyWithoutOwnLine))
        #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't restyle 'Odd' — "))
    }

    @Test("A read-only board refuses the write without a second banner")
    func readOnlyBoardRefusesQuietly() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.enterVanishedRootLock()
        let before = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")

        store.applyStyle(to: .items([card1]), background: .set("obsidian"))

        #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == before)
        #expect(store.banners.oneShots.isEmpty, "the lock row is already standing")
        #expect(store.bannerRows.contains { $0.id == "read-only-lock" })
    }

    // MARK: Subjects and levels

    @Test("Subjects are the board's targets in display order, with their current values")
    func subjectsAreBoardTargetsInDisplayOrder() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        let subjects = store.styleSubjects(of: .items([card2, card1, lane1]))
        #expect(subjects.map(\.id) == [lane1, card1, card2], "lane first, then its cards top to bottom")
        #expect(subjects.map(\.background) == [.missing, .valid("fern"), .missing])
        #expect(subjects.last?.folder.lastPathComponent == Ident.card2)

        // The board is always exactly one subject, at the root.
        let board = store.styleSubjects(of: .board)
        #expect(board.count == 1)
        let boardSubject = try #require(board.first)
        #expect(boardSubject.id == nil, "a board root has no ItemID by design")
        #expect(boardSubject.folder == fixture.root)
    }

    @Test("The level a target sits at decides the symbol grid's leading well")
    func levelFollowsTheTarget() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        #expect(store.styleLevel(of: .board) == .board)
        #expect(store.styleLevel(of: .items([lane1, lane2])) == .lane)
        #expect(store.styleLevel(of: .items([card1])) == .card)
        #expect(ItemSymbol.default(for: store.styleLevel(of: .items([card1]))) == ItemSymbol.card)
        #expect(ItemSymbol.default(for: store.styleLevel(of: .items([lane1]))) == ItemSymbol.lane)
        #expect(ItemSymbol.default(for: .board) == ItemSymbol.board)
    }

    /// **The curated symbol grid's finer question** (`CuratedSymbols.availableForStyleEditor`'s own
    /// doc comment): unlike `styleLevel(of:)`, which collapses a target naming both a lane and a card
    /// to `.card`, `styleTargetSpansLevels` says so plainly — a target the live UI can never build
    /// (04-interactions.md's cards-XOR-lanes rule) but that `styleSubjects`' own fixture above
    /// (`.items([card2, card1, lane1])`) shows is perfectly constructible by hand.
    @Test("A target naming both a lane and a card is flagged spanning; a homogeneous or board target is not")
    func spansLevelsDetectsAGenuineMix() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        #expect(store.styleTargetSpansLevels(.board) == false)
        #expect(store.styleTargetSpansLevels(.items([lane1, lane2])) == false)
        #expect(store.styleTargetSpansLevels(.items([card1, card2])) == false)
        #expect(store.styleTargetSpansLevels(.items([lane1, card1])) == true)
    }
}
