import Foundation
import Testing
@testable import Kanban

/// `BoardStore.setLaneWidth` — the one commit point both width mechanisms share (03-board-ui.md §
/// Lane): the right-edge drag's release and the ⌥⌘→/⌥⌘← stepper.
///
/// These drive a real store over a real temp board and then read the **raw bytes** back, never the
/// app's own read path, because the interesting claims are about the file: the integer that lands,
/// the stamps that follow it, and everything else surviving byte-for-byte. `WriterFixture`, `Ident`
/// and `Item` come from `WriterTestSupport.swift`, as they do for every other write suite.

// MARK: - Fixtures

/// A lane index carrying an explicit `width:` — `Item.rich` deliberately has none, and half of what
/// is under test here is what happens to a value that is already there (valid or not).
private func laneText(order: String, title: String, width: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    width: \(width)
    project: lanework       # agent overlay
    created: 2026-01-01T09:00:00Z
    modified: 2026-02-02T09:00:00Z
    modified-by: claude
    ---
    \(title) body.

    """
}

/// A board with one plain lane (no `width`), one already at 3×, and one whose frontmatter is
/// readable but uneditable.
@MainActor
private func makeBoard() throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", Item.board)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
    try fixture.item(Ident.lane3, Item.uneditable)
    return fixture
}

/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
/// through a resize byte-for-byte, in order.
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("width:") && !$0.hasPrefix("kind:")
    }
}

private func loadedLane(_ id: String, in fixture: WriterFixture) throws -> Lane {
    let model = try BoardLoader.load(boardRoot: fixture.root).model
    return try #require(model.lanes.first { $0.id.rawValue == id })
}

// MARK: - Tests

@MainActor
@Suite("BoardStore ▸ lane width")
struct LaneWidthWriteTests {

    @Test("A width change writes the integer, stamps modified, clears modified-by, and touches nothing else")
    func writesTheIntegerAndStamps() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText(Ident.lane1)

        store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 3)

        let after = try fixture.indexText(Ident.lane1)
        #expect(after.contains("width: 3"))
        #expect(!after.contains("modified-by"), "an app-mediated write clears an external writer's attribution")
        #expect(!after.contains("modified: 2026-02-02T09:00:00Z"), "the stamp is fresh")

        // Everything the write does not own survives exactly, in order: the unknown key with its
        // inline comment, the reserved `labels`, the original `created`, and the body.
        #expect(untouchedLines(after) == untouchedLines(before))

        let lane = try loadedLane(Ident.lane1, in: fixture)
        #expect(lane.width == .valid(3))
        #expect(lane.modifiedBy.isMissing)
        let modified = try #require(lane.modified.value)
        #expect(abs(modified.timeIntervalSinceNow) < 60, "modified is stamped with the time of the write")

        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A width change replaces whatever was there — a malformed value included")
    func replacesAMalformedValue() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "wide"))
        let store = try BoardStore(rootURL: fixture.root)

        // `width: wide` is lenient on the read side — it renders as one unit with the bytes left
        // alone (01-storage-format.md § Frontmatter). An explicit change is the user overwriting
        // it, so the Writer puts a plain integer in its place.
        #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.width == .malformed(raw: "wide"))
        store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 2)

        let after = try fixture.indexText(Ident.lane1)
        #expect(after.contains("width: 2"))
        #expect(!after.contains("wide"))
        #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2))
    }

    @Test("A count below one clamps to one rather than writing a value the read side would reject")
    func clampsBelowOne() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Lane two is at 3×, so a clamped 1 is a real change and actually reaches disk — where the
        // remove-at-default rule turns it into an absent key (03-board-ui.md § Lane, settled), which
        // the read side displays as one unit.
        store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 0)
        #expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing)

        try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3"))
        let reopened = try BoardStore(rootURL: fixture.root)
        reopened.setLaneWidth(ItemID(rawValue: Ident.lane2), units: -7)
        #expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing)
    }

    @Test("A lane that is not in the snapshot is a no-op")
    func unknownLaneIsANoOp() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(Ident.lane1)

        // The lane vanished under the gesture (or never existed): the reload that removed it is the
        // authority, and inventing a file here would be the app disagreeing with disk.
        store.setLaneWidth(ItemID(rawValue: Ident.indexless), units: 3)

        #expect(!fixture.exists(Ident.indexless))
        #expect(try fixture.indexData(Ident.lane1) == before)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("Setting the width a lane already displays writes nothing at all")
    func unchangedWidthIsANoOp() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let atThree = try fixture.indexData(Ident.lane2)
        let atOne = try fixture.indexData(Ident.lane1)

        // A drag that ends where it started, and a stepper pressed against its floor: neither may
        // stamp `modified`.
        store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 3)
        #expect(try fixture.indexData(Ident.lane2) == atThree)

        // Lane one has no `width` key at all, so it *displays* one unit — setting one unit is the
        // same no-op, and must not materialize the key.
        store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1)
        #expect(try fixture.indexData(Ident.lane1) == atOne)
        #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either")
    }

    @Test("A width landing on one removes the key — the remove-at-default family")
    func landingOnOneRemovesTheKey() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Lane two carries `width: 3`; stepping it down to one must not leave `width: 1` behind —
        // "a default lane's frontmatter stays clean, drag, stepper, and menu items alike"
        // (03-board-ui.md § Lane, settled). The read side then displays one unit off the absent key.
        store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 1)

        let after = try fixture.indexText(Ident.lane2)
        #expect(!after.contains("width"))
        #expect(try loadedLane(Ident.lane2, in: fixture).width.isMissing)
    }

    @Test("A hand-written width of one is preserved until the app itself next edits width")
    func handWrittenOneIsPreserved() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: "1"))
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(Ident.lane1)

        // Displays one unit, asked for one unit: the unchanged-units guard fires before the
        // remove-at-default rule can, so the legal hand-written `width: 1` stays byte-for-byte.
        store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 1)
        #expect(try fixture.indexData(Ident.lane1) == before)
    }

    @Test("The menu batch steps every selected lane one unit in one bracket, floor members holding")
    func batchStepsEverySelectedLane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let ids: Set<ItemID> = [ItemID(rawValue: Ident.lane1), ItemID(rawValue: Ident.lane2)]

        // Lane one displays 1× (no key), lane two 3×: one decrease holds the floor member and
        // steps the other — "each selected lane steps one unit, one gesture, one commit"
        // (03-board-ui.md § Lane, settled).
        store.stepLaneWidths(ids, by: -1)
        #expect(try loadedLane(Ident.lane1, in: fixture).width.isMissing, "held at the floor, key never materializes")
        #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(2))

        // A fresh store, because the step reads the snapshot and the first write's reload is the
        // watcher's (async) — the second gesture in a real session steps off the reloaded board.
        let reopened = try BoardStore(rootURL: fixture.root)
        reopened.stepLaneWidths(ids, by: 1)
        #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2))
        #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(3))
    }

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

        store.setLaneWidth(ItemID(rawValue: Ident.lane3), units: 2)

        // The refusal is the settled readable-but-uneditable rule: the file loads and renders, but
        // a surgical edit of it cannot be expressed, so nothing is written.
        #expect(try fixture.indexData(Ident.lane3) == before)
        #expect(try fixture.indexText(Ident.lane3) == Item.uneditable)

        // `performWrite` posts before it rethrows, and `setLaneWidth` swallows the rethrow — the
        // banner is the only thing that says the gesture did not happen, so it must be there.
        #expect(store.banners.oneShots.count == 1)
        let posted = try #require(store.banners.oneShots.first)
        #expect(posted.error.operation == .resize(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 resize 'Odd' — "))
        #expect(store.bannerRows.contains { $0.id == "one-shot:\(posted.id.uuidString)" })
    }

    @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)

        store.setLaneWidth(ItemID(rawValue: Ident.lane1), units: 4)

        // The lock row is already standing; a refusal per gesture would bury it under echoes of
        // itself (02-architecture.md § Write-failure surfacing).
        #expect(try fixture.indexData(Ident.lane1) == before)
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.bannerRows.contains { $0.id == "read-only-lock" })
    }
}
