import Foundation
import Testing
@testable import Kanban

/// `BannerCenter` is two things, and this suite is two suites: an **ordering rule** that is a pure
/// function over six row classes, and the **product's voice** — every sentence a user reads when a
/// write fails. The first is tested the way pure functions are, with no store and no filesystem;
/// the second is tested for the properties phrasing has to have (non-empty, distinct per
/// operation, titles quoted when known and graceful when not) rather than by pinning literals that
/// would turn every copy edit into a failing test.
///
/// The third part — that a failed write *reaches* the strip at all — needs a real store and a real
/// Writer failure, because the claim is about `performWrite`'s wiring rather than about the center.
/// Those tests are at the bottom, over `WriterTestSupport`'s fixtures like every other suite here.

// MARK: - Fixtures

/// `WriterFixture`, `Ident` and `Item` come from `WriterTestSupport.swift`.

/// A board with one lane whose frontmatter is readable but **uneditable** — the whole-frontmatter
/// flow mapping. It loads and renders fine; any app write to it refuses loudly, which is the
/// cheapest genuine Writer failure there is (no permissions to fiddle with, no disk to fill).
@MainActor
private func makeBoardWithUneditableLane() 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, Item.uneditable)
    return fixture
}

private func error(
    _ operation: WriteOperation,
    _ reason: BoardWriteError.Reason = .io(message: "the disk is full")
) -> BoardWriteError {
    BoardWriteError(operation: operation, path: "/Boards/Work/lane/index.md", reason: reason)
}

/// One representative error per `WriteOperation` case. Spelled out rather than derived so that a
/// new case added to the enum shows up here as a missing entry the moment anyone looks — the
/// switch in `actionPhrase(for:)` is the compile-time guard; this is the reading guard.
private let everyOperation: [WriteOperation] = [
    .createBoard,
    .createLane,
    .createCard,
    .move(title: "Fix login"),
    .reorder(title: "Fix login"),
    .copy(title: "Fix login"),
    .delete(title: "Fix login"),
    .purge(title: "Fix login"),
    .style(title: "Fix login"),
    .resize(title: "Fix login"),
    .collapse(title: "Todo"),
    .expand(title: "Todo"),
    .rename(title: "Fix login"),
    .importAttachment(filename: "photo.png"),
    .listAttachments,
    .renumberChildren,
]

/// The titled cases whose untitled fallback is the family's kind-free "the item" — the invariant the
/// loop below reads. `.collapse` / `.expand` are deliberately absent: only a lane carries `collapsed`,
/// so their fallback names the kind outright ("Couldn't collapse the lane") and there is no guess for
/// the rule to protect against (see `BannerCenter.actionPhrase(for:)`).
private let titledOperations: [(with: WriteOperation, without: WriteOperation)] = [
    (.move(title: "Fix login"), .move(title: nil)),
    (.reorder(title: "Fix login"), .reorder(title: nil)),
    (.copy(title: "Fix login"), .copy(title: nil)),
    (.delete(title: "Fix login"), .delete(title: nil)),
    (.purge(title: "Fix login"), .purge(title: nil)),
    (.style(title: "Fix login"), .style(title: nil)),
    (.resize(title: "Fix login"), .resize(title: nil)),
    (.rename(title: "Fix login"), .rename(title: nil)),
]

// MARK: - Ordering

@MainActor
@Suite("BannerCenter ▸ ordering")
struct BannerCenterOrderingTests {

    @Test("Every class present produces the settled precedence order")
    func everyClassOrdersByPrecedence() {
        let move = OneShotBanner(error: error(.move(title: "Fix login")), occurredAt: Date(timeIntervalSince1970: 100))
        let attachment = OneShotBanner(
            error: error(.importAttachment(filename: "photo.png")),
            occurredAt: Date(timeIntervalSince1970: 200)
        )
        let loss = LossBanner(
            message: "Pasted 'Fix login' without its 3 attachments",
            occurredAt: Date(timeIntervalSince1970: 150)
        )
        let operation = InProgressOperation(label: "Duplicating…")
        let signpost = InfoSignpost(message: "This card changed on the remote")

        let rows = BannerCenter.rows(
            lock: .vanishedRoot,
            breakage: BoardLoadFailure(BoardLoadError(path: "todo/index.md", reason: .missingOrder)),
            oneShots: [attachment, move],
            losses: [loss],
            suspension: HistorySuspension(reason: "disk full", since: Date(timeIntervalSince1970: 50)),
            operations: [operation],
            signposts: [signpost]
        )

        // in-progress (pinned) > read-only lock > reload breakage > one-shot failures > loss rows >
        // commit and attachment failures > passive info rows. The two info classes sit at opposite
        // ends of the strip.
        #expect(rows.map(\.id) == [
            "operation:\(operation.id.uuidString)",
            "read-only-lock",
            "reload-breakage",
            "one-shot:\(move.id.uuidString)",
            "loss:\(loss.id.uuidString)",
            "history-suspension",
            "one-shot:\(attachment.id.uuidString)",
            "signpost:\(signpost.id.uuidString)",
        ])
        #expect(rows.map(\.tone) == [.info, .error, .error, .error, .warning, .warning, .error, .info])
        #expect(rows.map(\.isPinned) == [true, false, false, false, false, false, false, false],
                "a spinner may never hide behind '+N more' — nothing else is pinned")
    }

    @Test("An attachment failure ranks below other one-shots even when it is newer")
    func attachmentFailuresRankLast() {
        let attachment = OneShotBanner(
            error: error(.importAttachment(filename: "photo.png")),
            occurredAt: Date(timeIntervalSince1970: 900)
        )
        let style = OneShotBanner(error: error(.style(title: "Old")), occurredAt: Date(timeIntervalSince1970: 1))

        let rows = BannerCenter.rows(
            lock: nil, breakage: nil, oneShots: [attachment, style], losses: [], suspension: nil, operations: []
        )

        #expect(rows.map(\.id) == ["one-shot:\(style.id.uuidString)", "one-shot:\(attachment.id.uuidString)"],
                "precedence class outranks recency; recency only orders within a class")
    }

    @Test("A loss row ranks below one-shot write failures and above history suspension, attachment one-shots, and signposts")
    func lossRankBetweenFailuresAndAmbientNotices() {
        let center = BannerCenter()
        center.post(error(.move(title: "Fix login")))
        center.post(error(.importAttachment(filename: "photo.png")))
        center.postLoss("Pasted 'Old ticket' without its 2 attachments")
        center.postSignpost("This card changed on the remote")

        let rows = BannerCenter.rows(
            lock: nil,
            breakage: nil,
            oneShots: center.oneShots,
            losses: center.losses,
            suspension: HistorySuspension(reason: "disk full"),
            operations: [],
            signposts: center.signposts
        )

        // move (non-attachment one-shot) > loss > history suspension > attachment one-shot > signpost.
        #expect(rows.map(\.id) == [
            "one-shot:\(center.oneShots[1].id.uuidString)", // "Fix login" move, posted first, listed second (newest-first)
            "loss:\(center.losses[0].id.uuidString)",
            "history-suspension",
            "one-shot:\(center.oneShots[0].id.uuidString)", // the attachment import
            "signpost:\(center.signposts[0].id.uuidString)",
        ])
        #expect(rows[1].tone == .warning)
    }

    @Test("One-shots order newest first within their class")
    func oneShotsAreNewestFirst() {
        let oldest = OneShotBanner(error: error(.move(title: "A")), occurredAt: Date(timeIntervalSince1970: 1))
        let middle = OneShotBanner(error: error(.move(title: "B")), occurredAt: Date(timeIntervalSince1970: 2))
        let newest = OneShotBanner(error: error(.move(title: "C")), occurredAt: Date(timeIntervalSince1970: 3))

        let rows = BannerCenter.rows(
            lock: nil,
            breakage: nil,
            oneShots: [middle, oldest, newest],
            losses: [],
            suspension: nil,
            operations: []
        )

        #expect(rows.map(\.id) == [
            "one-shot:\(newest.id.uuidString)",
            "one-shot:\(middle.id.uuidString)",
            "one-shot:\(oldest.id.uuidString)",
        ])
    }

    @Test("Loss rows order newest first within their class")
    func lossesAreNewestFirst() {
        let oldest = LossBanner(message: "A", occurredAt: Date(timeIntervalSince1970: 1))
        let middle = LossBanner(message: "B", occurredAt: Date(timeIntervalSince1970: 2))
        let newest = LossBanner(message: "C", occurredAt: Date(timeIntervalSince1970: 3))

        let rows = BannerCenter.rows(
            lock: nil,
            breakage: nil,
            oneShots: [],
            losses: [middle, oldest, newest],
            suspension: nil,
            operations: []
        )

        #expect(rows.map(\.id) == [
            "loss:\(newest.id.uuidString)",
            "loss:\(middle.id.uuidString)",
            "loss:\(oldest.id.uuidString)",
        ])
    }

    @Test("Failures sharing a timestamp keep the order they were posted in")
    func tiesAreStable() {
        let center = BannerCenter()
        // Posted in one run loop turn: `Date()` may well hand both the same value, and the strip
        // must not shuffle between renders because of it.
        center.post(error(.move(title: "First")))
        center.post(error(.move(title: "Second")))
        center.post(error(.move(title: "Third")))

        let rows = BannerCenter.rows(
            lock: nil,
            breakage: nil,
            oneShots: center.oneShots,
            losses: [],
            suspension: nil,
            operations: []
        )
        #expect(rows.map(\.headline).map { $0.contains("'Third'") } == [true, false, false])
        #expect(rows.count == 3)
    }

    @Test("Nothing standing is an empty strip")
    func quietBoardHasNoRows() {
        #expect(BannerCenter.rows(lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: []).isEmpty)
    }
}

// MARK: - Lifecycles

@MainActor
@Suite("BannerCenter ▸ lifecycles")
struct BannerCenterLifecycleTests {

    @Test("One-shots dismiss individually; conditions carry no dismiss control at all")
    func oneShotsDismissIndividually() throws {
        let center = BannerCenter()
        center.post(error(.move(title: "First")))
        center.post(error(.style(title: "Second")))
        #expect(center.oneShots.count == 2)

        let doomed = try #require(center.oneShots.first)
        center.dismiss(doomed.id)

        #expect(center.oneShots.count == 1)
        #expect(center.oneShots.first?.id != doomed.id, "dismissing one must not take its neighbour")

        // The condition rows have no id to dismiss with — the API shape *is* the rule ("conditions
        // heal", 02 § The banner surface), and this is where it is stated as a test.
        let rows = BannerCenter.rows(
            lock: .bracketedReloadFailed,
            breakage: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)),
            oneShots: center.oneShots,
            losses: [],
            suspension: HistorySuspension(reason: "disk full"),
            operations: [InProgressOperation(label: "Pulling…")]
        )
        #expect(rows.filter { $0.dismissID != nil }.count == 1, "only the one-shot may be dismissed")
    }

    @Test("Dismissing an in-progress operation's id does nothing — dismiss is not cancel")
    func dismissDoesNotEndOperations() {
        let center = BannerCenter()
        let id = center.beginOperation(label: "Importing…", cancel: nil)
        center.dismiss(id)
        #expect(center.operations.count == 1)
    }

    @Test("A loss row dismisses individually and is untimed — nothing but its own dismissal clears it")
    func lossRowsDismissByIDAndNeverExpire() throws {
        let center = BannerCenter()
        center.postLoss("Pasted 'Fix login' without its 3 attachments")
        center.postLoss("Folders can't be attached — 2 skipped")
        #expect(center.losses.count == 2)

        let doomed = try #require(center.losses.first)
        center.dismiss(doomed.id)

        #expect(center.losses.count == 1)
        #expect(center.losses.first?.id != doomed.id, "dismissing one must not take its neighbour")

        // No timer, no auto-expiry: everything else the center does — ending an in-progress
        // operation, raising and clearing the history suspension — leaves a standing loss alone.
        let id = center.beginOperation(label: "Pulling…", cancel: nil)
        center.endOperation(id)
        center.suspendHistory(reason: "disk full")
        center.clearHistorySuspension()
        #expect(center.losses.count == 1, "a loss survives everything except its own dismissal")
    }

    @Test("Dismissing all dismissable rows clears losses along with the failures and signposts")
    func dismissAllClearsLosses() {
        let center = BannerCenter()
        center.postLoss("Pasted 'Fix login' without its 3 attachments")
        center.postSignpost("This card changed on the remote")
        center.dismissAllDismissableRows()
        #expect(center.losses.isEmpty)
        #expect(center.signposts.isEmpty)
    }

    @Test("postSkippedFolders no-ops when nothing was skipped")
    func postSkippedFoldersNoOpsAtZero() {
        let center = BannerCenter()
        center.postSkippedFolders(count: 0)
        #expect(center.losses.isEmpty)

        center.postSkippedFolders(count: 2)
        #expect(center.losses.count == 1)
        #expect(center.losses[0].message == "Folders can't be attached — 2 skipped")
    }

    @Test("Suspending history raises a warning row; clearing it takes the row away")
    func historySuspensionIsAHealingCondition() throws {
        let center = BannerCenter()
        #expect(center.historySuspension == nil)

        center.suspendHistory(reason: "the repository is corrupt")
        let suspension = try #require(center.historySuspension)
        #expect(suspension.reason == "the repository is corrupt")

        let rows = BannerCenter.rows(
            lock: nil, breakage: nil, oneShots: [], losses: [], suspension: center.historySuspension, operations: []
        )
        #expect(rows.count == 1)
        #expect(rows[0].tone == .warning, "the files are safe; only the undo trail is degraded")
        #expect(rows[0].headline.contains("history"))
        #expect(rows[0].dismissID == nil)

        center.clearHistorySuspension()
        #expect(center.historySuspension == nil)
        #expect(BannerCenter.rows(
            lock: nil, breakage: nil, oneShots: [], losses: [], suspension: center.historySuspension, operations: []
        ).isEmpty)
    }

    @Test("Re-suspending keeps the original start and takes the newer diagnosis")
    func resuspendingKeepsTheClock() throws {
        let center = BannerCenter()
        center.suspendHistory(reason: "disk full")
        let first = try #require(center.historySuspension)

        center.suspendHistory(reason: "the repository is corrupt")
        let second = try #require(center.historySuspension)

        #expect(second.since == first.since, "the condition never stopped being true")
        #expect(second.reason == "the repository is corrupt")
    }

    @Test("Beginning an operation shows an info row; ending it clears the row")
    func operationsCompleteByLeaving() {
        let center = BannerCenter()
        let id = center.beginOperation(label: "Pulling…", cancel: nil)

        var rows = BannerCenter.rows(
            lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: center.operations
        )
        #expect(rows.count == 1)
        #expect(rows[0].tone == .info)
        #expect(rows[0].headline == "Pulling…")
        guard case let .inProgress(operation) = rows[0] else {
            Issue.record("expected an in-progress row")
            return
        }
        #expect(!operation.isCancelable, "an operation that cannot be abandoned gets no Cancel — settled")

        center.endOperation(id)
        rows = BannerCenter.rows(
            lock: nil, breakage: nil, oneShots: [], losses: [], suspension: nil, operations: center.operations
        )
        #expect(rows.isEmpty)
    }

    @Test("Copy-shaped work carries Cancel, and cancelling is the caller's closure")
    func cancelableOperationsRunTheirClosure() throws {
        let center = BannerCenter()
        let cancelled = Box()
        let id = center.beginOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })

        let operation = try #require(center.operations.first)
        #expect(operation.isCancelable)
        operation.cancel?()
        #expect(cancelled.value)

        center.endOperation(id)
        #expect(center.operations.isEmpty)
    }

    @Test("A signpost is a calm, dismissable row that ranks last and may collapse")
    func signpostsRankLastAndDismiss() throws {
        let center = BannerCenter()
        center.postSignpost("This card changed on the remote — your edits still win")
        center.post(error(.move(title: "Fix login")))

        let rows = BannerCenter.rows(
            lock: nil,
            breakage: nil,
            oneShots: center.oneShots,
            losses: [],
            suspension: nil,
            operations: center.operations,
            signposts: center.signposts
        )
        #expect(rows.count == 2)
        #expect(rows.last?.tone == .info, "same tone as an in-progress row, opposite end of the strip")
        #expect(rows.last?.isPinned == false, "calm by design — nothing is gated on seeing it instantly")

        let dismissID = try #require(rows.last?.dismissID)
        center.dismiss(dismissID)
        #expect(center.signposts.isEmpty)
        #expect(center.oneShots.count == 1, "dismissing a signpost leaves the failure standing")
    }

    @Test("A failed operation is an end plus a post — the row swaps for the error")
    func failureSwapsTheRow() {
        let center = BannerCenter()
        let id = center.beginOperation(label: "Importing 'photo.png'…", cancel: nil)
        center.endOperation(id)
        center.post(error(.importAttachment(filename: "photo.png"), .unreadable(message: "the source file could not be read")))

        let rows = BannerCenter.rows(
            lock: nil,
            breakage: nil,
            oneShots: center.oneShots,
            losses: [],
            suspension: nil,
            operations: center.operations
        )
        #expect(rows.count == 1)
        #expect(rows[0].tone == .error)
        #expect(rows[0].headline == "Couldn't import 'photo.png' — the source file could not be read")
    }
}

/// A one-field reference cell, so a `@Sendable` cancel closure has somewhere to record that it ran.
@MainActor
private final class Box {
    var value = false
}

// MARK: - A row's controls

/// **"Every control" is literal and includes banner-row buttons** (10-accessibility.md ▸ Full
/// Keyboard Access, ruled 2026-07-29): "a Dismiss or Cancel on a banner must be a Tab stop — FKA
/// serves sighted keyboard-only users, to whom VO custom actions are invisible, and Cancel on an
/// in-progress operation is exactly the control that cannot require a pointer."
///
/// The Tab loop itself is the focus system's and needs a window; what is assertable headlessly — and
/// what the strip renders straight from (`BannerStripView.trailingControls`) — is **which** controls
/// a row has and in what order, which is the half that could silently go missing.
@MainActor
@Suite("BannerRow ▸ controls")
struct BannerRowControlsTests {

    @Test("A cancelable in-progress row's one control is Cancel, and it runs the caller's closure")
    func inProgressRowsOfferCancel() throws {
        let cancelled = Box()
        let row = BannerRow.inProgress(
            InProgressOperation(label: "Importing 24 attachments…", cancel: { cancelled.value = true })
        )

        #expect(row.controls.map(\.label) == ["Cancel"], "a spinner is not dismissable — it completes or is cancelled")

        guard case let .cancel(action) = try #require(row.controls.first) else {
            Issue.record("expected a cancel control")
            return
        }
        action()
        #expect(cancelled.value)
    }

    @Test("An uncancelable bracket's row offers no control at all — no Cancel, nothing to dismiss")
    func uncancelableInProgressRowsOfferNothing() {
        let row = BannerRow.inProgress(InProgressOperation(label: "Rebuilding…"))

        #expect(row.controls.isEmpty)
    }

    @Test("Every dismissable row offers Dismiss, carrying the id the center dismisses by")
    func dismissableRowsOfferDismiss() throws {
        let banner = OneShotBanner(error: error(.move(title: "Fix login")))
        let loss = LossBanner(message: "Pasted 'Fix login' without its 3 attachments")
        let signpost = InfoSignpost(message: "This card changed on the remote — your edits still win")

        for (row, id) in [
            (BannerRow.oneShot(banner), banner.id),
            (BannerRow.loss(loss), loss.id),
            (BannerRow.signpost(signpost), signpost.id),
        ] {
            #expect(row.controls.map(\.label) == ["Dismiss"])
            guard case let .dismiss(carried) = try #require(row.controls.first) else {
                Issue.record("expected a dismiss control")
                return
            }
            #expect(carried == id, "the button dismisses this row, not whichever row is first")
        }
    }

    /// "A condition is never dismissed while it is still true" — the rows that heal on their own
    /// carry no button, so they contribute no tab stop either.
    @Test("Condition rows carry no controls")
    func conditionRowsCarryNoControls() {
        let rows: [BannerRow] = [
            .readOnlyLock(.vanishedRoot),
            .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
            .historySuspended(HistorySuspension(reason: "the volume is full")),
        ]

        for row in rows {
            #expect(row.controls.isEmpty, "\(row.id) is a condition — it heals, it is not waved away")
            #expect(row.dismissID == nil)
        }
    }

    /// The inventory is the view's source of truth, so it has to agree with `dismissID`, which the
    /// center's own dismissal path uses. One row, one answer.
    @Test("The inventory agrees with dismissID on every row class")
    func inventoryAgreesWithDismissID() {
        let rows: [BannerRow] = [
            .readOnlyLock(.vanishedRoot),
            .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "Todo/index.md", reason: .missingOrder))),
            .oneShot(OneShotBanner(error: error(.move(title: "Fix login")))),
            .loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
            .historySuspended(HistorySuspension(reason: "disk full")),
            .inProgress(InProgressOperation(label: "Duplicating…")),
            .signpost(InfoSignpost(message: "This card changed on the remote")),
        ]

        for row in rows {
            let dismisses = row.controls.contains { if case .dismiss = $0 { true } else { false } }
            #expect(dismisses == (row.dismissID != nil), "\(row.id)")
        }
    }
}

// MARK: - Phrasing

@MainActor
@Suite("BannerCenter ▸ phrasing")
struct BannerCenterPhrasingTests {

    @Test("Every write operation has its own non-empty headline")
    func everyOperationSaysSomethingDistinct() {
        let headlines = everyOperation.map { BannerCenter.headline(for: error($0)) }

        for (operation, headline) in zip(everyOperation, headlines) {
            #expect(!headline.isEmpty, "\(operation) has no headline")
            #expect(headline.hasPrefix("Couldn't "), "\(operation) does not name what failed")
            #expect(!headline.contains("nil"), "\(operation) leaked an optional into the product's voice")
        }
        #expect(Set(headlines).count == headlines.count, "two operations share a sentence — one of them is wrong")
    }

    @Test("Titles are quoted when known and the phrasing stays graceful when they are not")
    func titlesAreQuotedOrGracefullyAbsent() {
        for (withTitle, withoutTitle) in titledOperations {
            let named = BannerCenter.headline(for: error(withTitle))
            let anonymous = BannerCenter.headline(for: error(withoutTitle))

            #expect(named.contains("'Fix login'"), "\(withTitle) does not quote the title it carries")
            #expect(!anonymous.contains("''"), "\(withoutTitle) rendered an empty pair of quotes")
            #expect(anonymous.contains("the item"), "\(withoutTitle) should fall back to the kind-free noun")
            #expect(named != anonymous)
        }
    }

    @Test("Rename says the design's own sentence, and never borrows styling's")
    func renameHasItsOwnVerb() {
        // 02-architecture.md § Write-failure surfacing names this line verbatim when it settles
        // that "the vocabulary grows with the surfaces": inline rename gets its own case rather
        // than folding into the generic frontmatter bucket, so a failed rename must not tell the
        // user the app could not *restyle* anything.
        #expect(BannerCenter.headline(for: error(.rename(title: "Fix login"), .io(message: "the disk is full")))
            == "Couldn't rename 'Fix login' — the disk is full")
        #expect(BannerCenter.headline(for: error(.rename(title: nil), .io(message: "the disk is full")))
            == "Couldn't rename the item — the disk is full")
        #expect(BannerCenter.headline(for: error(.rename(title: "Fix login")))
            != BannerCenter.headline(for: error(.style(title: "Fix login"))))
    }

    @Test("The trash verbs speak the board's vocabulary, never the system Trash's")
    func trashVerbsFollowTheNamingConstraint() {
        // 03-board-ui.md § Trash's naming constraint, settled with the trash UI copy: two "Trash"
        // concepts coexist, and "Finder's 'Move to Trash' phrasing is reserved for the system Trash;
        // board deletion says 'Delete'". A banner is UI copy like any other.
        #expect(BannerCenter.headline(for: error(.delete(title: "Fix login"), .io(message: "the disk is full")))
            == "Couldn't delete 'Fix login' — the disk is full")
        #expect(BannerCenter.headline(for: error(.purge(title: "Fix login"), .io(message: "the disk is full")))
            == "Couldn't permanently delete 'Fix login' — the disk is full")

        for operation in [WriteOperation.delete(title: "Fix login"), .delete(title: nil)] {
            #expect(!BannerCenter.headline(for: error(operation)).contains("Trash"))
            #expect(!BannerCenter.headline(for: error(operation)).contains("trash"))
        }
    }

    @Test("The cause tail comes from the error's reason and nowhere else")
    func causeTailCarriesTheDiagnosis() {
        #expect(BannerCenter.headline(for: error(.move(title: "Fix login"), .io(message: "the disk is full")))
            == "Couldn't move 'Fix login' — the disk is full")

        // A trailing full stop in a diagnostic message must not survive into the middle of a line.
        #expect(BannerCenter.headline(for: error(.createCard, .io(message: "no space left on device.")))
            == "Couldn't create a card — no space left on device")

        // The uneditable-frontmatter reason is the one whose own `description` is written for a
        // developer, so the banner translates it rather than quoting it.
        let uneditable = BannerCenter.headline(for: error(.style(title: "Odd"), .uneditableFrontmatter(.keyWithoutOwnLine)))
        #expect(uneditable.hasPrefix("Couldn't restyle 'Odd' — "))
        #expect(uneditable.contains("frontmatter"))
    }

    @Test("Every lock reason says what is wrong and that the view is still the last good one")
    func lockHeadlinesReassure() {
        let reasons: [ReadOnlyLockReason] = [.bracketedReloadFailed, .vanishedRoot]
            + UnwritableCause.allCases.map(ReadOnlyLockReason.unwritableLocation)
        let headlines = reasons.map(BannerCenter.headline(for:))

        for headline in headlines {
            #expect(headline.contains("last good view"))
            #expect(headline.contains("read-only"))
        }
        #expect(Set(headlines).count == reasons.count)
    }

    /// "Which specific cause, not a shared line" (02 § Write-failure surfacing, settled): the two
    /// halves of the unwritable location name **different repairs**, so they get different lines.
    @Test("The unwritable location names which cause it is")
    func unwritableCausesGetTheirOwnLines() {
        let volume = BannerCenter.headline(for: .unwritableLocation(.readOnlyVolume))
        let folder = BannerCenter.headline(for: .unwritableLocation(.permissionDenied))

        #expect(volume == "This board's volume is read-only — showing the last good view, read-only")
        #expect(folder == "You don't have permission to change this folder — showing the last good view, read-only")

        // The distinction the design spends the extra line on: one says volume, the other says this
        // folder, and neither says the other's word.
        #expect(volume.contains("volume") && !volume.contains("permission"))
        #expect(folder.contains("permission") && !folder.contains("volume"))
    }

    @Test("Reload breakage carries fail-fast's specifics — the path and what is wrong with it")
    func breakageHeadlineNamesThePath() {
        let headline = BannerCenter.headline(
            for: BoardLoadFailure(BoardLoadError(
                path: "todo/fix-login/index.md",
                reason: .unparseableYAML(message: "unexpected end", line: 4)))
        )
        #expect(headline.contains("'todo/fix-login/index.md'"))
        #expect(headline.contains("line 4"))
        #expect(headline.contains("showing the last good view"))

        // The board's own index.md reports as "." — a lone dot in the product's voice would be a
        // bug report, not a sentence.
        let rootHeadline = BannerCenter.headline(
            for: BoardLoadFailure(BoardLoadError(path: ".", reason: .boardRootMissingIndex)))
        #expect(!rootHeadline.contains("'.'"))
        #expect(rootHeadline.hasPrefix("This board isn't loading"))
    }

    /// **One defect named, the rest counted** (01-storage-format.md § Malformed input — the loader
    /// collects every fail-fast defect in a walk). A banner is one line, so the sentence stays the
    /// sentence it always was and the remainder rides as a count; the full list is the decision
    /// surface's to show on the next attended open.
    ///
    /// Both spellings are pinned, because the single-defect one is what every existing surface reads
    /// and it must not have drifted when the aggregate arrived.
    @Test("A multi-defect breakage names the first and counts the rest")
    func breakageHeadlineCountsTheRest() {
        let first = BoardLoadError(path: "index.md", reason: .missingSchema)
        let second = BoardLoadError(path: "todo/index.md", reason: .schemaNewerThanApp(found: 2))
        let third = BoardLoadError(path: "done/index.md", reason: .malformedSchema(raw: "one"))

        #expect(BannerCenter.headline(for: BoardLoadFailure([first]))
            == "'index.md' isn't loading: missing required 'schema' field — showing the last good view")

        #expect(BannerCenter.headline(for: BoardLoadFailure([first, second]))
            == "'index.md' isn't loading: missing required 'schema' field, and 1 more — showing the last good view")

        #expect(BannerCenter.headline(for: BoardLoadFailure([first, second, third]))
            == "'index.md' isn't loading: missing required 'schema' field, and 2 more — showing the last good view")

        // The row that carries it says the same thing — the headline is not re-derived anywhere.
        #expect(BannerRow.reloadBreakage(BoardLoadFailure([first, second])).headline
            == BannerCenter.headline(for: BoardLoadFailure([first, second])))
    }

    @Test("The suspended-history line names the consequence, then the diagnosis")
    func suspensionHeadlineNamesTheConsequence() {
        #expect(BannerCenter.headline(for: HistorySuspension(reason: "the disk is full"))
            == "Changes aren't being recorded to history — the disk is full")
        #expect(BannerCenter.headline(for: HistorySuspension(reason: ""))
            == "Changes aren't being recorded to history")
    }

    @Test("The skipped-folders line matches 04's own example, plural and singular")
    func skippedFoldersMessageMatchesTheDesignExample() {
        // 04-interactions.md's own example sentence, verbatim.
        #expect(BannerCenter.skippedFoldersMessage(count: 2) == "Folders can't be attached — 2 skipped")
        // Singular keeps the same leading claim rather than recasting it as "A folder can't be
        // attached" — one sentence shape for every count (see the type's own doc comment).
        #expect(BannerCenter.skippedFoldersMessage(count: 1) == "Folders can't be attached — 1 skipped")
    }
}

// MARK: - Store integration

@MainActor
@Suite("BannerCenter ▸ store integration")
struct BannerCenterStoreTests {

    @Test("A failed write lands exactly one one-shot in the store's banners, and still throws")
    func failedWritePostsOnce() throws {
        let fixture = try makeBoardWithUneditableLane()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        // A genuine Writer refusal: the lane's frontmatter is a whole-frontmatter flow mapping, so
        // the surgical editor cannot address it and the write fails loudly rather than corrupting
        // the file (01-storage-format.md § Frontmatter).
        var thrown: BoardWriteError?
        do {
            try store.performWrite { () throws(BoardWriteError) -> Void in
                try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane2), operation: .style(title: nil)) { _ in }
            }
            Issue.record("expected the write to fail")
        } catch let failure as BoardWriteError {
            thrown = failure
        }

        let error = try #require(thrown)
        #expect(store.banners.oneShots.count == 1, "the banner is posted once, not per layer")
        #expect(store.banners.oneShots.first?.error == error, "the strip carries the same failure the caller saw")

        // And it is the whole of what the strip shows: a healthy board with one unread failure.
        #expect(store.bannerRows.count == 1)
        #expect(store.bannerRows[0].tone == .error)
        #expect(store.bannerRows[0].headline == "Couldn't restyle 'Odd' — this file's frontmatter can't be edited in place (a top-level key has no line of its own)")
        #expect(store.bannerRows[0].dismissID != nil)
    }

    @Test("A write refused by the read-only lock posts nothing — the lock row already stands")
    func refusalUnderLockPostsNothing() throws {
        let fixture = try makeBoardWithUneditableLane()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)
        store.enterVanishedRootLock()

        do {
            try store.performWrite { () throws(BoardWriteError) -> Void in
                try BoardWriter.updateIndex(inItemFolder: fixture.url(Ident.lane1), operation: .style(title: nil)) { _ in }
            }
            Issue.record("expected the store to refuse the write")
        } catch let refusal as BoardStoreWriteRefusal {
            #expect(refusal == .readOnlyLocked(.vanishedRoot))
        }

        #expect(store.banners.oneShots.isEmpty, "a refusal is not a failed write; the lock row is the message")
        #expect(store.bannerRows.count == 1)
        guard case .readOnlyLock(.vanishedRoot) = store.bannerRows[0] else {
            Issue.record("expected the lock row alone")
            return
        }
    }

    @Test("The store's rows compose its own conditions with the center's")
    func bannerRowsComposeBothHalves() throws {
        let fixture = try makeBoardWithUneditableLane()
        defer { fixture.tearDown() }
        let store = try BoardStore(rootURL: fixture.root)

        store.enterUnwritableLock(.permissionDenied)
        store.banners.post(BoardWriteError(operation: .createCard, path: "/x", reason: .io(message: "the disk is full")))
        store.banners.postLoss("Pasted 'Fix login' without its 3 attachments")
        store.banners.suspendHistory(reason: "the disk is full")
        store.banners.beginOperation(label: "Duplicating…", cancel: nil)
        store.banners.postSignpost("This card changed on the remote")

        #expect(store.bannerRows.map(\.id) == [
            "operation:\(store.banners.operations[0].id.uuidString)",
            "read-only-lock",
            "one-shot:\(store.banners.oneShots[0].id.uuidString)",
            "loss:\(store.banners.losses[0].id.uuidString)",
            "history-suspension",
            "signpost:\(store.banners.signposts[0].id.uuidString)",
        ])
    }
}
