import AppKit
import Foundation
import Testing
@testable import Kanban

/// The two inline editors' **write** paths — `BoardStore.commitRename` and
/// `BoardStore.commitPlaceholder` — plus the lane drag's `moveLane` and the board popover's
/// `renameBoard`, which is the same rename vocabulary aimed at the root.
///
/// Like `LaneWidthWriteTests`, these drive a real store over a real temp board and then read the
/// **raw bytes** back rather than the app's own read path: the interesting claims are about the
/// file — the key that lands or leaves, the stamps that follow, and everything else surviving
/// byte-for-byte. `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

// MARK: - Fixtures

private func tombstoned(order: String, title: String) -> String {
    """
    ---
    schema: 1
    title: \(title)
    order: \(order)
    deleted: 2026-03-03T09:00:00Z
    ---
    \(title) body.

    """
}

/// An item with no `title` key at all — the untitled state a rename can both start from and return
/// an item to.
private func untitled(order: String) -> String {
    """
    ---
    schema: 1
    order: \(order)
    project: lanework       # agent overlay
    created: 2026-01-01T09:00:00Z
    modified: 2026-02-02T09:00:00Z
    ---
    Some body.

    """
}

/// Two lanes: `lane1` with two cards, `lane2` with one, plus a readable-but-uneditable lane.
@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.lane1)/\(Ident.card1)", Item.rich(order: "1024", title: "First"))
    try fixture.item("\(Ident.lane1)/\(Ident.card2)", Item.rich(order: "2048", title: "Second"))
    try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Doing"))
    try fixture.item("\(Ident.lane2)/\(Ident.card3)", Item.rich(order: "1024", title: "Third"))
    try fixture.item(Ident.lane3, Item.uneditable)
    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 card1 = ItemID(rawValue: Ident.card1)
private let card2 = ItemID(rawValue: Ident.card2)
private let card3 = ItemID(rawValue: Ident.card3)

/// The file's lines minus the ones an app-mediated write is *supposed* to change — what must come
/// through a rename byte-for-byte, in order.
private func untouchedLines(_ text: String) -> [Substring] {
    text.split(separator: "\n", omittingEmptySubsequences: false).filter {
        // `kind:` joins the filtered keys as of 2026-07-29: a file without one gains it on the
        // first app write that rewrites it — the integrity service's on-touch heal, riding this
        // write's own atomic rewrite (01-storage-format.md § Validation and healing).
        !$0.hasPrefix("modified") && !$0.hasPrefix("title:") && !$0.hasPrefix("kind:")
    }
}

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 }
}

/// Every card folder under a lane, so a create can be spotted by what is newly there.
private func cardFolders(_ lane: String, in fixture: WriterFixture) throws -> Set<String> {
    Set(try fixture.entryNames(lane).filter { BoardLoader.isUUIDShaped($0) })
}

// MARK: - Rename

@MainActor
@Suite("BoardStore ▸ inline rename")
struct InlineRenameWriteTests {

    @Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
    func writesTheTitleAndStamps() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("Fix login")
        store.commitRename()

        let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
        #expect(after.contains("title: Fix login"))
        #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`, the `order`, and the body.
        #expect(untouchedLines(after) == untouchedLines(before))

        let renamed = try #require(card(card1, in: load(fixture)))
        #expect(renamed.title == .valid("Fix login"))
        #expect(renamed.modifiedBy.isMissing)
        let modified = try #require(renamed.modified.value)
        #expect(abs(modified.timeIntervalSinceNow) < 60)

        #expect(store.transient.renameEditor == nil, "the editor closes on commit")
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A lane renames through the same path")
    func renamesALane() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: lane2, currentTitle: "Doing")
        store.transient.updateRenameDraft("In progress")
        store.commitRename()

        #expect(try fixture.indexText(Ident.lane2).contains("title: In progress"))
        #expect(try load(fixture).lanes.first { $0.id == lane2 }?.title == .valid("In progress"))
    }

    @Test("An empty commit removes the title key, byte-faithfully")
    func emptyCommitRemovesTheKey() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("")
        store.commitRename()

        let after = try fixture.indexText("\(Ident.lane1)/\(Ident.card1)")
        // "Committing an empty rename on an existing item removes its `title` key" — the *key*, not
        // an empty string: titles are optional at every level, and `title: ""` would be a real (if
        // blank) title where the face should show the untitled placeholder.
        #expect(!after.contains("title:"))
        #expect(!after.contains("title: \"\""))
        #expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved")

        let stripped = try #require(card(card1, in: load(fixture)))
        #expect(stripped.title.isMissing)
        #expect(stripped.order == 1024, "the card keeps its place")
    }

    @Test("Whitespace commits as empty — a title of three spaces is a slip, not a name")
    func whitespaceIsEmpty() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("   ")
        store.commitRename()

        #expect(try #require(card(card1, in: load(fixture))).title.isMissing)

        // And a title with edges is trimmed rather than quoted into the file with its padding.
        store.transient.beginRename(of: card2, currentTitle: "Second")
        store.transient.updateRenameDraft("  Fix login  ")
        store.commitRename()
        #expect(try #require(card(card2, in: load(fixture))).title == .valid("Fix login"))
    }

    @Test("An unchanged title writes nothing at all")
    func unchangedTitleIsANoOp() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        try fixture.item("\(Ident.lane2)/\(Ident.card3)", untitled(order: "1024"))
        let store = try BoardStore(rootURL: fixture.root)
        let titled = try fixture.indexData("\(Ident.lane1)/\(Ident.card1)")
        let untouched = try fixture.indexData("\(Ident.lane2)/\(Ident.card3)")

        // An editor opened and dismissed with Return must not stamp `modified` — the lane-resize
        // rule, for the same reason.
        store.transient.beginRename(of: card1, currentTitle: "First")
        store.commitRename()
        #expect(try fixture.indexData("\(Ident.lane1)/\(Ident.card1)") == titled)

        // Same for an untitled item committed still untitled: the key must not materialize and then
        // vanish, nor the file be rewritten to say nothing new.
        store.transient.beginRename(of: card3, currentTitle: nil)
        store.commitRename()
        #expect(try fixture.indexData("\(Ident.lane2)/\(Ident.card3)") == untouched)
        #expect(try fixture.entryNames("\(Ident.lane2)/\(Ident.card3)") == ["index.md"], "no temp-file residue either")
    }

    @Test("A commit at a target the snapshot does not have writes nothing, silently")
    func vanishedTargetWritesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.entryNames("")

        // "A target that is tombstoned, deleted, or gone at commit time discards the editor and its
        // keystrokes silently" — silently being the operative word: nothing written, no banner.
        store.transient.beginRename(of: ItemID(rawValue: Ident.indexless), currentTitle: "Ghost")
        store.transient.updateRenameDraft("Never lands")
        store.commitRename()

        #expect(try fixture.entryNames("") == before)
        #expect(!fixture.exists(Ident.indexless))
        #expect(store.transient.renameEditor == nil)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A commit at a card that entered the trash writes nothing")
    func targetInTheTrashWritesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        // "Entering the trash is a vanish from the board; nothing is ever written into a vanished
        // folder" (04 ▸ Grammar).
        try fixture.move("\(Ident.lane1)/\(Ident.card1)", toTrash: Ident.card1)
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(".trash/\(Ident.card1)")

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("Never lands")
        store.commitRename()

        #expect(try fixture.indexData(".trash/\(Ident.card1)") == before)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A rename follows a foreign move — the write lands wherever the card now lives")
    func renameFollowsTheUUID() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("Fix login")

        try FileManager.default.moveItem(
            at: fixture.url("\(Ident.lane1)/\(Ident.card1)"),
            to: fixture.url("\(Ident.lane2)/\(Ident.card1)")
        )
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        store.commitRename()

        #expect(try fixture.indexText("\(Ident.lane2)/\(Ident.card1)").contains("title: Fix login"))
        #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.transient.beginRename(of: lane3, currentTitle: "Odd")
        store.transient.updateRenameDraft("Renamed")
        store.commitRename()

        #expect(try fixture.indexData(Ident.lane3) == before)
        #expect(store.banners.oneShots.count == 1)
        let posted = try #require(store.banners.oneShots.first)
        // The title is enriched off the document the write refused, so the banner names the item by
        // what it is still called rather than by the name that never landed.
        #expect(posted.error.operation == .rename(title: "Odd"))
        #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
    }

    @Test("A read-only board refuses the rename 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.transient.beginRename(of: card1, currentTitle: "First")
        store.transient.updateRenameDraft("Fix login")
        store.commitRename()

        #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: - The new-card placeholder's commit

@MainActor
@Suite("BoardStore ▸ new-card placeholder commit")
struct NewCardCommitWriteTests {

    @Test("A lane-bottom commit creates the card appended after the visible cards")
    func createsAtTheLaneBottom() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try cardFolders(Ident.lane1, in: fixture)

        store.transient.beginPlaceholder(inLane: lane1)
        store.transient.updateDraft("Fix login")
        let created = try #require(store.commitPlaceholder())

        let after = try cardFolders(Ident.lane1, in: fixture)
        #expect(after.subtracting(before) == [created.rawValue], "exactly one new card folder")

        let model = try load(fixture)
        let made = try #require(card(created, in: model))
        #expect(made.title == .valid("Fix login"))
        // Appended after the visible siblings at 1024 and 2048: `Ranks.append` is max + 1024.
        #expect(made.order == 3072)
        #expect(model.lanes.first { $0.id == lane1 }?.cards.map(\.id) == [card1, card2, created],
                "and it lands last in display order")

        // The overlay stands in for the card until the watcher round-trips it — it does not vanish
        // the instant the Writer returns (02-architecture.md § Layering).
        #expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(created))
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("An after-anchor commit lands the card between its anchor and the next card")
    func createsAfterItsAnchor() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // ⌘N with `card1` selected: "in that card's lane, immediately after it".
        store.transient.beginPlaceholder(inLane: lane1, after: card1)
        store.transient.updateDraft("Fix login")
        let created = try #require(store.commitPlaceholder())

        let model = try load(fixture)
        let made = try #require(card(created, in: model))
        #expect(made.order == 1536, "the midpoint of 1024 and 2048")
        #expect(model.lanes.first { $0.id == lane1 }?.cards.map(\.id) == [card1, created, card2])
        #expect(made.title == .valid("Fix login"))

        // The reposition rides the Writer's same-parent degenerate reorder, so exactly one file was
        // rewritten past the create: the neighbours keep their ranks.
        #expect(card(card1, in: model)?.order == 1024)
        #expect(card(card2, in: model)?.order == 2048)
    }

    @Test("An anchor that is already last is the lane's bottom — no reposition")
    func anchoringTheLastCardIsAnAppend() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane1, after: card2)
        store.transient.updateDraft("Fix login")
        let created = try #require(store.commitPlaceholder())

        #expect(try #require(card(created, in: load(fixture))).order == 3072)
    }

    @Test("An anchor that vanished degrades to the lane's bottom rather than refusing")
    func aVanishedAnchorAppends() async throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane1, after: card1)
        store.transient.updateDraft("Fix login")

        // An agent deletes the anchor mid-typing. The lane — the anchor that actually matters — is
        // still there, so the card the user is creating is theirs to keep.
        try FileManager.default.removeItem(at: fixture.url("\(Ident.lane1)/\(Ident.card1)"))
        store.handleWatcherEvent(.treeChanged(.foreign))
        await store.awaitQuiescence()

        let created = try #require(store.commitPlaceholder())
        #expect(try #require(card(created, in: load(fixture))).order == 3072)
    }

    @Test("An empty commit discards the draft and writes nothing")
    func emptyCommitCreatesNothing() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try cardFolders(Ident.lane1, in: fixture)

        // "Creating-then-abandoning never leaves an empty card behind" — untitled cards exist only
        // when made deliberately (04-interactions.md ▸ Grammar).
        store.transient.beginPlaceholder(inLane: lane1)
        store.transient.updateDraft("   ")
        #expect(store.commitPlaceholder() == nil)

        #expect(try cardFolders(Ident.lane1, in: fixture) == before)
        #expect(store.transient.newCardPlaceholder == nil)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A commit into a lane the snapshot has lost discards the draft")
    func vanishedLaneDiscards() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: ItemID(rawValue: Ident.indexless))
        store.transient.updateDraft("Fix login")
        #expect(store.commitPlaceholder() == nil)

        #expect(!fixture.exists(Ident.indexless))
        #expect(store.transient.newCardPlaceholder == nil)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A failed create discards the placeholder and banners the failure")
    func aFailedCreateDiscards() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // Read-execute only: the lane loads and renders, but no folder can be minted inside it.
        let laneFolder = fixture.url(Ident.lane2)
        try FileManager.default.setAttributes([.posixPermissions: 0o555], ofItemAtPath: laneFolder.path)
        defer { try? FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: laneFolder.path) }

        store.transient.beginPlaceholder(inLane: lane2)
        store.transient.updateDraft("Fix login")
        #expect(store.commitPlaceholder() == nil)

        // Settled in 02-architecture.md § Layering: "if the Writer create throws after the title
        // commits, the create flow discards the placeholder … the overlay never waits for a card
        // that cannot arrive". The failure is the banner's, not the overlay's.
        #expect(store.transient.newCardPlaceholder == nil)
        #expect(store.banners.oneShots.count == 1)
        #expect(store.banners.oneShots.first?.error.operation == .createCard)
    }

    @Test("A read-only board refuses the create 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 cardFolders(Ident.lane1, in: fixture)

        store.transient.beginPlaceholder(inLane: lane1)
        store.transient.updateDraft("Fix login")
        #expect(store.commitPlaceholder() == nil)

        #expect(try cardFolders(Ident.lane1, in: fixture) == before)
        #expect(store.transient.newCardPlaceholder == nil)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("A committed placeholder is not committed twice")
    func commitIsIdempotent() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.transient.beginPlaceholder(inLane: lane1)
        store.transient.updateDraft("Fix login")
        let created = try #require(store.commitPlaceholder())
        let after = try cardFolders(Ident.lane1, in: fixture)

        // Return commits and the field's focus-loss handler fires an instant later; the second call
        // must not file a duplicate.
        #expect(store.commitPlaceholder() == nil)
        #expect(try cardFolders(Ident.lane1, in: fixture) == after)
        #expect(store.transient.newCardPlaceholder?.phase == .awaitingArrival(created))
    }
}

// MARK: - Lane reorder

@MainActor
@Suite("BoardStore ▸ lane reorder")
struct LaneReorderWriteTests {

    @Test("A lane moved to the head takes a rank before every sibling")
    func movesToTheHead() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // lane1 @1024, lane2 @2048, lane3 @1024 (`Item.uneditable`'s own order; the folder-name
        // tie-break puts it after lane1). Moving the last lane to index 0 puts it at min − 1024.
        store.moveLane(lane2, toIndex: 0)

        let model = try load(fixture)
        #expect(model.lanes.map(\.id) == [lane2, lane1, lane3])
        #expect(model.lanes.first { $0.id == lane2 }?.order == 0)
    }

    @Test("A lane moved between two siblings takes their midpoint")
    func movesBetweenSiblings() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two"))
        try fixture.item(Ident.lane3, Item.rich(order: "3072", title: "Three"))
        let store = try BoardStore(rootURL: fixture.root)

        // With lane1 removed the remaining pair is [lane2 @2048, lane3 @3072]; landing at index 1
        // is between them.
        store.moveLane(lane1, toIndex: 1)

        let model = try load(fixture)
        #expect(model.lanes.map(\.id) == [lane2, lane1, lane3])
        #expect(model.lanes.first { $0.id == lane1 }?.order == 2560)
        // "A reorder rewrites only the moved item's `index.md`" — the neighbours keep their ranks.
        #expect(model.lanes.first { $0.id == lane2 }?.order == 2048)
        #expect(model.lanes.first { $0.id == lane3 }?.order == 3072)
    }

    @Test("A lane moved to the end is appended past every sibling")
    func movesToTheEnd() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two"))
        let store = try BoardStore(rootURL: fixture.root)

        store.moveLane(lane1, toIndex: 1)

        let model = try load(fixture)
        #expect(model.lanes.map(\.id) == [lane2, lane1])
        #expect(model.lanes.first { $0.id == lane1 }?.order == 3072)
    }

    @Test("A drag that ends where it started writes nothing at all")
    func unchangedIndexIsANoOp() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(Ident.lane1)

        // Index 0 is where lane1 already sits, counted with itself removed — the release must not
        // stamp `modified`.
        store.moveLane(lane1, toIndex: 0)

        #expect(try fixture.indexData(Ident.lane1) == before)
        // The writer's temp files are hidden, so only a listing that sees them can prove there is
        // no residue from a write that should never have started.
        #expect(try !fixture.entryNames(Ident.lane1).contains { $0.hasPrefix(".") })
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("Exhausted precision compacts the board and then places the lane")
    func exhaustedPrecisionRenumbers() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        // Two lanes on adjacent Doubles: no rank exists between them (01-storage-format.md §
        // Ordering's renumber trigger), which is exactly what the compaction fallback is for.
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
        try fixture.item(Ident.lane2, Item.rich(order: "2048", title: "Two"))
        try fixture.item(Ident.lane3, Item.rich(order: "2048.0000000000005", title: "Three"))
        let store = try BoardStore(rootURL: fixture.root)
        #expect(Ranks.midpoint(between: 2048, and: 2048.0000000000005) == nil, "the gap really is exhausted")

        // lane1 to the slot between lane2 and lane3.
        store.moveLane(lane1, toIndex: 1)

        let model = try load(fixture)
        #expect(model.lanes.map(\.id) == [lane2, lane1, lane3])
        // The compaction runs first and is **sequence-preserving** — it rewrites the ladder to
        // 1024/2048/3072 in the display order the board already had (lane1, lane2, lane3), so
        // nothing visibly moves. Only then is the dragged lane placed, at the midpoint of the fresh
        // gap between lane2 and lane3.
        #expect(model.lanes.first { $0.id == lane2 }?.order == 2048)
        #expect(model.lanes.first { $0.id == lane1 }?.order == 2560)
        #expect(model.lanes.first { $0.id == lane3 }?.order == 3072,
                "the exhausted rank was compacted away rather than worked around")
    }

    @Test("A lane that is not on the board is a no-op, and a read-only board refuses quietly")
    func unknownLaneAndLockedBoard() throws {
        let fixture = try makeBoard()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexData(Ident.lane1)

        store.moveLane(ItemID(rawValue: Ident.indexless), toIndex: 0)
        #expect(try fixture.indexData(Ident.lane1) == before)

        store.enterVanishedRootLock()
        store.moveLane(lane1, toIndex: 2)
        #expect(try fixture.indexData(Ident.lane1) == before)
        #expect(store.banners.oneShots.isEmpty)
        #expect(store.bannerRows.contains { $0.id == "read-only-lock" })
    }
}

// MARK: - New lane

@MainActor
@Suite("BoardStore ▸ new lane")
struct NewLaneWriteTests {

    @Test("New Lane appends an untitled lane at the board's right end")
    func createsAnUntitledLane() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "One"))
        let store = try BoardStore(rootURL: fixture.root)

        store.createLane()

        let model = try load(fixture)
        #expect(model.lanes.count == 2)
        let made = try #require(model.lanes.last)
        #expect(made.id != lane1)
        // No `title` key at all — the folder-name fallback is a board-level rule, and a lane with no
        // title renders the untitled placeholder until Board ▸ Rename gives it one.
        #expect(made.title.isMissing)
        #expect(made.order == 2048)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("New Lane is the way out of a zero-lane board")
    func createsTheFirstLane() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        let store = try BoardStore(rootURL: fixture.root)
        #expect(store.snapshot.lanes.isEmpty)

        store.createLane()

        let model = try load(fixture)
        #expect(model.lanes.count == 1)
        #expect(model.lanes.first?.order == 1024, "an empty parent's first child lands at the board convention")
    }

    @Test("A read-only board refuses the create without a second banner")
    func readOnlyBoardRefusesQuietly() throws {
        let fixture = try WriterFixture()
        defer { fixture.tearDown() }
        try fixture.item("", Item.board)
        let store = try BoardStore(rootURL: fixture.root)
        store.enterVanishedRootLock()

        store.createLane()

        #expect(try load(fixture).lanes.isEmpty)
        #expect(store.banners.oneShots.isEmpty)
    }
}

// MARK: - The board rename

/// The board root's own `index.md` — richer than `Item.board` because a rename has to leave all of
/// it alone: an unknown key with its inline comment, a `created` from before today, a foreign
/// `modified-by`, and the board description body.
private let richBoardIndex = """
---
schema: 1
title: Roadmap
project: lanework       # agent overlay
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
modified-by: claude
---
Board description — with *markdown*.

"""

/// A board with no `title` key at all: the folder-name fallback state a rename both starts from and
/// returns a board to (01-storage-format.md § Board naming).
private let untitledBoardIndex = """
---
schema: 1
created: 2026-01-01T09:00:00Z
modified: 2026-02-02T09:00:00Z
---
Board description.

"""

/// Readable, uneditable, at board level: the whole-frontmatter flow mapping, which loads and renders
/// fine but has no line for the editor to key on.
private let uneditableBoardIndex = "---\n{schema: 1, title: Odd}\n---\nodd body\n"

/// A board root and one lane, so the fixture is a board the store will actually load.
@MainActor
private func makeBoardRoot(_ index: String) throws -> WriterFixture {
    let fixture = try WriterFixture()
    try fixture.item("", index)
    try fixture.item(Ident.lane1, Item.rich(order: "1024", title: "Todo"))
    return fixture
}

@MainActor
@Suite("BoardStore ▸ board rename")
struct BoardRenameWriteTests {

    @Test("A non-empty commit writes the title, stamps modified, and touches nothing else")
    func writesTheTitleAndStamps() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText("")

        store.renameBoard("Q3 plan")

        let after = try fixture.indexText("")
        #expect(after.contains("title: Q3 plan"))
        #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")
        // The unknown key with its comment, the original `created`, and the description survive
        // exactly, in order — the round-trip contract, at board level like every other.
        #expect(untouchedLines(after) == untouchedLines(before))

        let model = try load(fixture)
        #expect(model.title == .valid("Q3 plan"))
        #expect(model.modifiedBy.isMissing)
        let modified = try #require(model.modified.value)
        #expect(abs(modified.timeIntervalSinceNow) < 60)
        #expect(store.banners.oneShots.isEmpty)
    }

    @Test("The folder is never renamed — only the frontmatter moves")
    func theFolderIsLeftAlone() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let name = fixture.root.lastPathComponent
        let store = try BoardStore(rootURL: fixture.root)

        store.renameBoard("Something else entirely")

        // "The folder is never renamed by the app; the Finder document name is Finder's to change"
        // (03-board-ui.md § Board popover) — the display name and the document name may diverge.
        #expect(fixture.root.lastPathComponent == name)
        #expect(fixture.exists(""))
        #expect(store.rootURL == fixture.root)
    }

    @Test("An empty commit removes the title key and the board falls back to its folder name")
    func emptyCommitRemovesTheKey() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let before = try fixture.indexText("")

        store.renameBoard("")

        let after = try fixture.indexText("")
        #expect(!after.contains("title:"))
        #expect(!after.contains("title: \"\""), "the key leaves; it is never blanked")
        #expect(untouchedLines(after) == untouchedLines(before), "only the title line and the stamps moved")
        #expect(try load(fixture).title.isMissing)

        // And the fallback is the *folder* name, never "Untitled" — 01-storage-format.md's
        // board-naming rule, which is also what the empty field's placeholder promises.
        let reopened = try BoardStore(rootURL: fixture.root)
        #expect(AppModel.displayName(of: reopened) == fixture.root.deletingPathExtension().lastPathComponent)
    }

    @Test("nil commits as empty — the field's absent-title state and its cleared state agree")
    func nilRemovesTheKeyToo() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.renameBoard(nil)

        #expect(try !fixture.indexText("").contains("title:"))
        #expect(try load(fixture).title.isMissing)
    }

    @Test("Whitespace commits as empty, and a padded title is trimmed rather than quoted")
    func whitespaceIsEmpty() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.renameBoard("   ")
        #expect(try load(fixture).title.isMissing)

        let padded = try makeBoardRoot(richBoardIndex)
        defer { padded.tearDown() }
        let other = try BoardStore(rootURL: padded.root)
        other.renameBoard("  Q3 plan  ")
        #expect(try load(padded).title == .valid("Q3 plan"))
    }

    @Test("An unchanged title writes nothing at all")
    func unchangedTitleIsANoOp() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let titled = try fixture.indexData("")

        // A popover opened and dismissed with Return must not stamp `modified` — `commitRename`'s
        // rule, for its reason.
        store.renameBoard("Roadmap")
        #expect(try fixture.indexData("") == titled)

        // Same for an untitled board committed still untitled: the key must not materialize and
        // then vanish, nor the file be rewritten to say nothing new.
        let untitled = try makeBoardRoot(untitledBoardIndex)
        defer { untitled.tearDown() }
        let untouched = try untitled.indexData("")
        let second = try BoardStore(rootURL: untitled.root)

        second.renameBoard("")
        #expect(try untitled.indexData("") == untouched)
        second.renameBoard(nil)
        #expect(try untitled.indexData("") == untouched)
        #expect(try untitled.entryNames("").sorted() == [Ident.lane1, "index.md"], "no temp-file residue either")
    }

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

        store.renameBoard("Renamed")

        #expect(try fixture.indexData("") == before)
        #expect(store.banners.oneShots.count == 1)
        let posted = try #require(store.banners.oneShots.first)
        // Enriched off the document the write refused, so the banner names the board by what it is
        // still called rather than by the name that never landed.
        #expect(posted.error.operation == .rename(title: "Odd"))
        #expect(BannerCenter.headline(for: posted.error).hasPrefix("Couldn't rename 'Odd' — "))
    }

    @Test("A read-only board refuses the rename without a second banner")
    func readOnlyBoardRefusesQuietly() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.enterVanishedRootLock()
        let before = try fixture.indexData("")

        store.renameBoard("Q3 plan")

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

// MARK: - The board popover's presentation

@MainActor
@Suite("Board popover ▸ presentation")
struct BoardInfoPresentationTests {

    @Test("⌘I toggles: it opens a closed popover and closes an open one")
    func toggles() {
        let presentation = BoardInfoPresentation()
        #expect(!presentation.isPresented, "a window opens with its popover closed")

        presentation.toggle()
        #expect(presentation.isPresented)

        presentation.toggle()
        #expect(!presentation.isPresented, "the shortcut is the keyboard's way back out")
    }

    @Test("Two board windows never fight over one flag")
    func isPerWindow() {
        let first = BoardInfoPresentation()
        let second = BoardInfoPresentation()

        first.toggle()

        #expect(first.isPresented)
        #expect(!second.isPresented)
    }
}

// MARK: - The window-title widget

/// The AppKit half of the board popover: the titlebar accessory and the SwiftUI widget it hosts.
///
/// Deliberately **not** a test of what the widget looks like or of the popover's behaviour, neither
/// of which a unit test can see. It is a test that a window survives having one — an accessory is
/// installed during window setup, where a mistake crashes at open rather than misdraws, and this is
/// the cheapest place to find that out. `HostedWindowController`'s two promises about it (once per
/// window, off again on detach) are checked at the same time, because both are the kind of thing a
/// later refactor breaks silently.
@MainActor
@Suite("Board popover ▸ the window-title widget")
struct BoardInfoAccessoryTests {

    /// A defaults domain of this suite's own — `StyleEditorView` reaches the recents list, and a
    /// test that wrote into `UserDefaults.standard` would be editing the developer's quick-style row.
    private func makeRecents() -> (recents: StyleRecents, teardown: () -> Void) {
        let name = "BoardInfoAccessoryTests-\(UUID().uuidString)"
        guard let defaults = UserDefaults(suiteName: name) else {
            Issue.record("could not create a defaults suite")
            return (StyleRecents(defaults: .standard), {})
        }
        return (StyleRecents(defaults: defaults), { defaults.removePersistentDomain(forName: name) })
    }

    private func makeWindow() -> NSWindow {
        NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 800, height: 500),
            styleMask: [.titled, .closable, .resizable],
            backing: .buffered,
            defer: false
        )
    }

    @Test("It installs on a real window, refuses a second, and comes back off on detach")
    func installsOnceAndRemoves() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (recents, teardown) = makeRecents()
        defer { teardown() }

        let window = makeWindow()
        let controller = HostedWindowController()
        controller.attach(to: window)

        controller.installTitlebarAccessory(
            boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
        )
        #expect(window.titlebarAccessoryViewControllers.count == 1)

        // A host that configures itself twice must not give the titlebar two chevrons — AppKit keeps
        // accessories in an array and would happily hold both.
        controller.installTitlebarAccessory(
            boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
        )
        #expect(window.titlebarAccessoryViewControllers.count == 1)

        controller.detach()
        #expect(window.titlebarAccessoryViewControllers.isEmpty, "the window is SwiftUI's; what we hang on it comes off")
    }

    @Test("An accessory handed over before the window exists goes in when one arrives")
    func installsAfterTheWindowAttaches() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (recents, teardown) = makeRecents()
        defer { teardown() }

        // The board window's real order: the load returns (and the accessory is built) before or
        // after `viewDidMoveToWindow`, and neither ordering may drop it.
        let controller = HostedWindowController()
        controller.installTitlebarAccessory(
            boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
        )

        let window = makeWindow()
        controller.attach(to: window)

        #expect(window.titlebarAccessoryViewControllers.count == 1)
        controller.detach()
    }

    /// **The card that widened the widget from a chevron to the whole board-name area also hides the
    /// title bar's own title on board windows** (`BoardWindowHost.configureWindow`'s two calls, in the
    /// same order: install the widget, then `hideTitle()`) — the widget now says the board's name, so
    /// the system title display would only repeat it, exactly the card-window seam
    /// `CardWindowTitleVisibilityTests` pins below. This test cannot drive `BoardWindowHost.body`
    /// itself (it needs a live `AppModel` session), so it pins the two calls composed the same way, in
    /// the same order, against a real window — which is what would break silently if a later change
    /// separated them or reordered them.
    @Test("Installing the widget and hiding the title compose without one clobbering the other")
    func hidesTitleAlongsideTheAccessory() throws {
        let fixture = try makeBoardRoot(richBoardIndex)
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        let (recents, teardown) = makeRecents()
        defer { teardown() }

        let window = makeWindow()
        window.title = "My Board"
        let controller = HostedWindowController()
        controller.attach(to: window)

        controller.installTitlebarAccessory(
            boardInfoTitlebarAccessory(store: store, recents: recents, presentation: BoardInfoPresentation())
        )
        controller.hideTitle()

        #expect(window.titlebarAccessoryViewControllers.count == 1)
        #expect(window.titleVisibility == .hidden, "the widget says the name now; the chrome shouldn't say it twice")
        #expect(window.title == "My Board", "the Window menu, Exposé and restoration still need the string")
    }
}

// MARK: - Card window chrome ▸ hidden title

/// **Card and board windows show no title in the title bar** — a card's name is shown as part of its
/// body instead (05-card-window.md ▸ Window: "card title is shown as part of card's body"), and a
/// board's is said by the board-popover widget in the titlebar instead (03-board-ui.md ▸ Board
/// popover) — while the toolbar renders exactly as it always has on both. `HostedWindowController
/// .hideTitle()` is the seam both `CardWindowHost` and `BoardWindowHost` reach for; only the
/// restore-bootstrap window calls no such thing and keeps AppKit's own default. Like
/// `BoardInfoAccessoryTests` above, this cannot see what a user sees on screen — only that the
/// mechanism this app owns (`NSWindow.titleVisibility`) lands correctly, survives the macOS 26
/// provisional-window swap (`HostedWindowController.detach`'s doc comment — chrome held by the
/// controller reapplies to whichever window actually sticks around), and leaves `window.title` itself
/// alone, since that string is what the Window menu, Mission Control/Exposé, VoiceOver and state
/// restoration all read.
@MainActor
@Suite("Card window chrome ▸ hidden title")
struct CardWindowTitleVisibilityTests {

    private func makeWindow(title: String = "Fix login") -> NSWindow {
        let window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 400, height: 300),
            styleMask: [.titled, .closable, .resizable],
            backing: .buffered,
            defer: false
        )
        window.title = title
        return window
    }

    @Test("hideTitle() hides the title bar's title but leaves window.title alone")
    func hidesTitleLeavesTitleStringAlone() {
        let window = makeWindow()
        let controller = HostedWindowController()
        controller.attach(to: window)

        controller.hideTitle()

        #expect(window.titleVisibility == .hidden)
        #expect(window.title == "Fix login", "the Window menu, Exposé and restoration still need the string")
    }

    @Test("A window nobody calls hideTitle() on keeps AppKit's default — the restore-bootstrap window's posture")
    func unconfiguredWindowsKeepTheDefault() {
        let window = makeWindow()
        let controller = HostedWindowController()
        controller.attach(to: window)

        #expect(window.titleVisibility == .visible)
    }

    @Test("Hiding before the window exists still lands once one attaches")
    func hidesBeforeAttach() {
        let controller = HostedWindowController()
        controller.hideTitle()

        let window = makeWindow()
        controller.attach(to: window)

        #expect(window.titleVisibility == .hidden)
    }

    @Test("The setting survives the provisional-window swap: detach, then attach to the real window")
    func survivesDetachAttachCycle() {
        let provisional = makeWindow()
        let controller = HostedWindowController()
        controller.attach(to: provisional)
        controller.hideTitle()
        #expect(provisional.titleVisibility == .hidden)

        controller.detach()

        let real = makeWindow(title: "Fix login")
        controller.attach(to: real)

        #expect(real.titleVisibility == .hidden, "reapplied on the window that actually sticks around")
        #expect(real.title == "Fix login", "window.title still flows independently of the chrome swap")
    }
}
