import CoreGraphics
import Foundation
import Testing
@testable import Kanban

/// `BoardStore.setLaneCollapsed` — the one commit point every collapse gesture shares (03-board-ui.md
/// § Lane ▸ Collapsed lanes): the header chevron, the context menu's Collapse/Expand Lane row, and a
/// click on the collapsed strip's body.
///
/// `LaneWidthWriteTests`' suite, one key over, and deliberately its twin: collapse is document state
/// exactly as width is, so the claims worth making are the same claims — the value that lands, the
/// stamps that follow it, the remove-at-default rule, and everything else surviving byte-for-byte. Real
/// stores over real temp boards, read back as **raw bytes** rather than through the app's own read path.
/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

// MARK: - Fixtures

/// A lane index carrying an explicit `collapsed:` beside a `width:` — half of what is under test is
/// what happens to values that are already there, and the other half is that `width` is not one of them.
private func laneText(order: String, title: String, width: String?, collapsed: String?) -> String {
    let widthLine = width.map { "width: \($0)\n" } ?? ""
    let collapsedLine = collapsed.map { "collapsed: \($0)\n" } ?? ""
    return """
        ---
        schema: 1
        title: \(title)
        order: \(order)
        \(widthLine)\(collapsedLine)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, one already folded 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, laneText(order: "1024", title: "Todo", width: "2", collapsed: nil))
    try fixture.item(Ident.lane2, laneText(order: "2048", title: "Doing", width: "3", collapsed: "true"))
    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 fold byte-for-byte, in order. `width:` is deliberately **not** in the exclusion list: the
/// whole point is that a collapse leaves it alone.
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("collapsed:") && !$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 collapse")
struct LaneCollapseWriteTests {

    @Test("A collapse writes `collapsed: true`, stamps modified, clears modified-by, and touches nothing else")
    func collapseWritesTheKeyAndStamps() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText(Ident.lane1)

        store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)

        let after = try fixture.indexText(Ident.lane1)
        #expect(after.contains("collapsed: true"))
        #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 — **`width: 2` included**, which
        // is the whole of "expanding restores the lane the user had".
        #expect(untouchedLines(after) == untouchedLines(before))
        #expect(after.contains("width: 2"))

        let lane = try loadedLane(Ident.lane1, in: fixture)
        #expect(lane.collapsed == .valid(true))
        #expect(lane.width == .valid(2), "the fold does not read or rewrite the width key")
        #expect(LaneLayoutMath.isCollapsed(lane))
        #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("Expanding removes the key rather than writing `false` — the remove-at-default family")
    func expandRemovesTheKey() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false)

        let after = try fixture.indexText(Ident.lane2)
        #expect(!after.contains("collapsed"), "a default lane's frontmatter stays clean")
        #expect(!after.contains("false"))
        let lane = try loadedLane(Ident.lane2, in: fixture)
        #expect(lane.collapsed.isMissing)
        #expect(!LaneLayoutMath.isCollapsed(lane))
        // And the preserved width is what the lane comes back as.
        #expect(lane.width == .valid(3))
        #expect(after.contains("width: 3"))
    }

    @Test("A fold survives a width change, and a width change survives a fold")
    func theTwoKeysAreIndependent() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // The stepper still edits the preserved key while the lane is folded — 03's "the width stepper
        // and ⌥⌘→/⌥⌘← still edit the preserved key, which is what the lane will be when it unfolds".
        store.setLaneWidth(ItemID(rawValue: Ident.lane2), units: 5)
        let afterResize = try loadedLane(Ident.lane2, in: fixture)
        #expect(afterResize.width == .valid(5))
        #expect(afterResize.collapsed == .valid(true), "resizing a folded lane does not unfold it")

        let reopened = try BoardStore(rootURL: fixture.root)
        reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false)
        #expect(try loadedLane(Ident.lane2, in: fixture).width == .valid(5))
    }

    @Test("Setting the state a lane already reads writes nothing at all")
    func unchangedStateIsANoOp() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let folded = try fixture.indexData(Ident.lane2)
        let expanded = try fixture.indexData(Ident.lane1)

        // Neither may stamp `modified`: a second Collapse on a folded lane, and an Expand on a lane
        // that has no key to remove.
        store.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: true)
        #expect(try fixture.indexData(Ident.lane2) == folded)

        store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false)
        #expect(try fixture.indexData(Ident.lane1) == expanded)
        #expect(try fixture.entryNames(Ident.lane1) == ["index.md"], "no temp-file residue either")
    }

    @Test("A hand-written `collapsed: false` reads as expanded and is preserved until the app edits it")
    func handWrittenFalseIsPreserved() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "false"))
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(Ident.lane1)

        #expect(!LaneLayoutMath.isCollapsed(try loadedLane(Ident.lane1, in: fixture)))
        // Reads as expanded, asked to expand: the unchanged-state guard fires before the
        // remove-at-default rule can, so the legal hand edit stays byte-for-byte — `width: 1`'s rule.
        store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: false)
        #expect(try fixture.indexData(Ident.lane1) == before)

        // A real change replaces it with the one shape the app writes.
        store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)
        let after = try fixture.indexText(Ident.lane1)
        #expect(after.contains("collapsed: true"))
        #expect(!after.contains("collapsed: false"))
    }

    @Test("A value with no boolean reading is replaced on collapse and removed on expand")
    func aMalformedValueIsOverwritten() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item(Ident.lane1, laneText(order: "1024", title: "Todo", width: nil, collapsed: "maybe"))
        let store = try BoardStore(rootURL: fixture.root)

        // Lenient on the read side — renders expanded with the bytes left alone — so a collapse is a
        // real change and overwrites it.
        #expect(store.snapshot.lanes.first { $0.id.rawValue == Ident.lane1 }?.collapsed
            == .malformed(raw: "maybe"))
        store.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)

        let after = try fixture.indexText(Ident.lane1)
        #expect(after.contains("collapsed: true"))
        #expect(!after.contains("maybe"))
        #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true))
    }

    @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.setLaneCollapsed(ItemID(rawValue: Ident.indexless), collapsed: true)

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

    @Test("Collapsing a lane whose card is selected selects the lane")
    func collapsingReSelectsTheLane() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.board()
        try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
        try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First")
        try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
        let store = try BoardStore(rootURL: fixture.root)
        let lane = ItemID(rawValue: Ident.lane1)
        let card = ItemID(rawValue: Ident.card1)

        store.select([card], in: .board)
        store.setLaneCollapsed(lane, collapsed: true)

        // The card stops being rendered, so a selection naming it would leave the arrows with no frame
        // to step from — the lane the user just folded is what they are holding instead.
        #expect(store.selection.ids == [lane])
        #expect(store.selection.container == .board)

        // A selection elsewhere is untouched, and so is one of the lane itself.
        let other = ItemID(rawValue: Ident.lane2)
        store.select([other], in: .board)
        store.setLaneCollapsed(lane, collapsed: false)
        #expect(store.selection.ids == [other], "expanding never touches the selection")
    }

    @Test("Undo puts the fold back, and each direction names itself")
    func undoAndRedoRoundTrip() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let history = NativeHistoryProvider()
        store.history = history
        let lane = ItemID(rawValue: Ident.lane1)

        store.setLaneCollapsed(lane, collapsed: true)
        #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true))
        #expect(history.undoActionName == "Collapse Lane")

        history.undo()
        #expect(try loadedLane(Ident.lane1, in: fixture).collapsed.isMissing,
                "the inverse removes the key rather than writing `false`")
        #expect(try loadedLane(Ident.lane1, in: fixture).width == .valid(2),
                "and it never touched the width")

        history.redo()
        #expect(try loadedLane(Ident.lane1, in: fixture).collapsed == .valid(true))

        // The other direction names itself: an expand's row must not read "Collapse".
        let reopened = try BoardStore(rootURL: fixture.root)
        let reopenedHistory = NativeHistoryProvider()
        reopened.history = reopenedHistory
        reopened.setLaneCollapsed(ItemID(rawValue: Ident.lane2), collapsed: false)
        #expect(reopenedHistory.undoActionName == "Expand Lane")
        reopenedHistory.undo()
        #expect(try loadedLane(Ident.lane2, in: fixture).collapsed == .valid(true))
    }

    @Test("A readable-but-uneditable lane refuses the write, banners it in the gesture's own word, 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.setLaneCollapsed(ItemID(rawValue: Ident.lane3), collapsed: true)

        #expect(try fixture.indexData(Ident.lane3) == before)
        #expect(try fixture.indexText(Ident.lane3) == Item.uneditable)

        // `performWrite` posts before it rethrows and the store swallows the rethrow — the banner is the
        // only thing that says the gesture did not happen, and it must say *collapse* rather than
        // resize: the user pressed Collapse Lane.
        #expect(store.banners.oneShots.count == 1)
        let posted = try #require(store.banners.oneShots.first)
        #expect(posted.error.operation == .collapse(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 collapse 'Odd' — "))
    }

    /// **A drop on the strip appends at the lane's end** (03-board-ui.md § Lane ▸ Collapsed lanes:
    /// "dropping a drag onto the collapsed strip appends the payload at the lane's END, like an
    /// end-of-lane drop").
    ///
    /// It is asserted with **no window at all**, which is the point rather than a convenience: a folded
    /// lane draws no cards, so there is no row geometry a position could be resolved against and nothing
    /// about the pointer can change the answer — the branch is taken before the cursor is ever read.
    @Test("A card drag over a folded lane proposes the end of its card list, cursor notwithstanding")
    func dropOnTheStripAppendsAtTheEnd() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.board()
        try fixture.lane(Ident.lane1, order: "1024", title: "Todo")
        try fixture.card(Ident.card1, in: Ident.lane1, order: "1024", title: "First")
        try fixture.card(Ident.card2, in: Ident.lane1, order: "2048", title: "Second")
        try fixture.card(Ident.card3, in: Ident.lane1, order: "3072", title: "Third")
        // The destination: folded, holding two cards of its own.
        try fixture.item(Ident.lane2, "---\nschema: 1\ntitle: Doing\norder: 2048\ncollapsed: true\n---\n\n")
        try fixture.card(Ident.card4, in: Ident.lane2, order: "1024", title: "Fourth")
        try fixture.card(Ident.indexless, in: Ident.lane2, order: "2048", title: "Fifth")

        let store = try BoardStore(rootURL: fixture.root)
        let session = DragSession()
        let registry = LaneDropRegistry()
        let drops = BoardDropContext(
            store: store,
            session: session,
            registry: registry,
            gap: 12,
            window: { nil },
            stripFrame: { .zero },
            standard: { 260 }
        )
        let dragged = ItemID(rawValue: Ident.card1)
        session.beginCards(
            [dragged],
            folders: [store.rootURL
                .appendingPathComponent(Ident.lane1, isDirectory: true)
                .appendingPathComponent(Ident.card1, isDirectory: true)],
            heights: [44],
            container: .board,
            source: store
        )

        drops.retargetCards(inLane: ItemID(rawValue: Ident.lane2))
        let proposal = try #require(session.proposal)
        #expect(proposal.container == .lane(ItemID(rawValue: Ident.lane2)))
        #expect(proposal.index == 2, "the end of the folded lane's two cards")

        // Into the lane the run came *from*, the index is counted in the resting layout — the dragged
        // card lifted out — which is the space every proposal in this app is counted in.
        drops.retargetCards(inLane: ItemID(rawValue: Ident.lane1))
        #expect(session.proposal?.index == 2, "three cards minus the one in flight")

        // Expanded, the lane goes back to needing geometry: with no window and no registered grid there
        // is nothing to resolve, and the standing proposal simply holds (the hysteresis contract).
        try fixture.lane(Ident.lane2, order: "2048", title: "Doing")
        let reopened = try BoardStore(rootURL: fixture.root)
        let reopenedDrops = BoardDropContext(
            store: reopened, session: session, registry: registry, gap: 12,
            window: { nil }, stripFrame: { .zero }, standard: { 260 }
        )
        session.propose(nil)
        reopenedDrops.retargetCards(inLane: ItemID(rawValue: Ident.lane2))
        #expect(session.proposal == nil, "an expanded lane answers from its grid, and there is none here")
    }

    @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.setLaneCollapsed(ItemID(rawValue: Ident.lane1), collapsed: true)

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

}
