import Foundation
import Observation

// MARK: - Tone

/// The three kinds a banner row can be (02-architecture.md § The banner surface, "Tones, not
/// components"): one layout, one accessibility path, three colorings. The card window's
/// remote-change signpost (07-sync-collab.md) is this same component in `.info` — visually calm,
/// no error color — which is precisely why the distinction is a *tone* on a shared row rather
/// than three view types that would drift apart.
public enum BannerTone: Sendable, Equatable {
    case error
    case warning
    case info
}

// MARK: - Row payloads

/// A one-shot write failure: it happened once, it is over, and it waits to be read
/// (02-architecture.md § The banner surface, "One-shots dismiss, conditions heal").
///
/// **No timeout, ever** — "an error never evaporates unread". The row leaves the strip only when
/// the user dismisses it, which is why it carries an id of its own rather than being identified by
/// its content: two identical failures a minute apart are two rows, and dismissing one must not
/// take the other with it.
public struct OneShotBanner: Identifiable, Sendable, Equatable {
    public let id: UUID
    public let error: BoardWriteError
    /// When the failure happened — the sort key for "newest first within a class".
    public let occurredAt: Date

    public init(id: UUID = UUID(), error: BoardWriteError, occurredAt: Date = Date()) {
        self.id = id
        self.error = error
        self.occurredAt = occurredAt
    }
}

/// The standing condition "changes aren't being recorded to history" (02-architecture.md §
/// Write-failure surfacing, "Auto-commit failures beyond `index.lock` contention").
///
/// `reason` is **diagnostic English, not the banner's verb** — the free-form tail the phrasing
/// rules allow ("disk full", "the repository is corrupt"). The user-facing sentence is
/// `BannerCenter.headline(for:)`'s, like every other row's.
///
/// `since` exists because this is a condition, not an event: it started at some point and has been
/// true ever since. Nothing renders it today; it is what a later "suspended for 4 minutes" reading
/// would be built from, and recording it costs a `Date`.
public struct HistorySuspension: Sendable, Equatable {
    public let reason: String
    public let since: Date

    public init(reason: String, since: Date = Date()) {
        self.reason = reason
        self.since = since
    }
}

/// A calm notice that something happened elsewhere — the info tone's *passive* half.
///
/// Its one producer-to-be is the card window's **remote-change signpost** (07-sync-collab.md): a
/// pull landed commits touching the very card being edited, the dirty buffer stays put and wins,
/// and the window says so without a modal or a merge UI. 02-architecture.md calls that "this same
/// component in the info tone", which is exactly what this is.
///
/// **Ranks last and may collapse** (settled, § The banner surface): "calm by design, nothing gated
/// on seeing them instantly" — the opposite end of the strip from an in-progress row, whose spinner
/// may never hide.
///
/// Dismissable, like a one-shot and unlike a condition: it reports something that already happened,
/// so there is nothing for it to heal into.
public struct InfoSignpost: Identifiable, Sendable, Equatable {
    public let id: UUID
    public let message: String
    public let occurredAt: Date

    public init(id: UUID = UUID(), message: String, occurredAt: Date = Date()) {
        self.id = id
        self.message = message
        self.occurredAt = occurredAt
    }
}

/// Work in flight, shown as an info row with a spinner (02-architecture.md § The banner surface,
/// "In-progress operations are info rows"): bracketed git operations ("Pulling…", "Switching to
/// 'main'…") and long non-git work (big-board Duplicate, template instantiation, large attachment
/// imports).
///
/// `label` is the caller's, deliberately: unlike a failure — whose vocabulary is the closed
/// `WriteOperation` enum precisely so the banner can own every word — an in-progress row names an
/// operation the banner layer has no enum for, and inventing one would mean a new git verb could
/// not describe itself without touching this file. The tradeoff is stated rather than hidden.
///
/// **Cancel appears on safe copies only** (settled): copy-shaped work — attachment imports,
/// Duplicate, template instantiation — carries `cancel`, meaning "remove the partial copy, nothing
/// lost". Git brackets pass `nil`: they are seconds long, and aborting a rebase mid-flight is a
/// repair job, not a cancel.
public struct InProgressOperation: Identifiable, Sendable {
    public let id: UUID
    public let label: String
    public var isCancelable: Bool { cancel != nil }
    /// `@MainActor @Sendable` rather than the plainer `@MainActor () -> Void` the sketch carried:
    /// this value is stored in a `Sendable` struct, and a non-`Sendable` function type would make
    /// that conformance a lie under strict concurrency. The isolation is unchanged — cancelling
    /// runs on the main actor, where the operation's own state lives.
    public let cancel: (@MainActor @Sendable () -> Void)?

    public init(id: UUID = UUID(), label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) {
        self.id = id
        self.label = label
        self.cancel = cancel
    }
}

// MARK: - Rows

/// One row in a window's banner strip.
///
/// The six cases are the whole vocabulary of 02-architecture.md § The banner surface, and they
/// divide into three lifecycles that the view renders differently and that the ordering rule
/// treats as classes:
///
/// - **Conditions heal**: `readOnlyLock`, `reloadBreakage`, `historySuspended`. They describe
///   ongoing state and carry no dismiss control — "an error never evaporates unread" has a twin,
///   "a condition is never dismissed while it is still true". Each leaves when the thing it
///   describes stops being true.
/// - **One-shots dismiss**: `oneShot` and `signpost`. Each reports something that already happened,
///   so only the user can clear it.
/// - **In-progress rows complete or fail**: `inProgress`. Completion clears the row; failure swaps
///   it for a one-shot (`BannerCenter.endOperation(_:)` + `post(_:)`).
///
/// **`Identifiable` by a synthetic string id**, not by content: the condition rows are singletons
/// per window (there is one lock, one breakage, one history suspension), so their ids are constant
/// and a changing *reason* updates the row rather than replacing it — no view churn, no lost
/// animation, and no diffing surprise when a lock's cause changes underneath a standing row.
public enum BannerRow: Identifiable, Sendable {
    /// The board refuses writes. Condition, error tone. Producers: the failed bracketed reload
    /// (built), the vanished root (this milestone), the open-time writability probe (m4).
    case readOnlyLock(ReadOnlyLockReason)
    /// A reload failed and the last good snapshot is still on screen. Condition, error tone.
    case reloadBreakage(BoardLoadError)
    /// A write that did not happen. Dismissable, error tone.
    case oneShot(OneShotBanner)
    /// History has stopped advancing. Condition, warning tone — the files are safe, only the undo
    /// trail is degraded, which is a warning rather than an error. (m7's committer drives it.)
    case historySuspended(HistorySuspension)
    /// Work in flight. Info tone, spinner, **pinned above everything** and exempt from the collapse.
    /// (m5's copy-shaped work and m7's git brackets drive it.)
    case inProgress(InProgressOperation)
    /// A calm notice — the passive half of the info tone, ranking last of all. (m6's card window
    /// drives it, as 07-sync-collab.md's remote-change signpost.)
    case signpost(InfoSignpost)

    public var id: String {
        switch self {
        case .readOnlyLock: "read-only-lock"
        case .reloadBreakage: "reload-breakage"
        case let .oneShot(banner): "one-shot:\(banner.id.uuidString)"
        case .historySuspended: "history-suspension"
        case let .inProgress(operation): "operation:\(operation.id.uuidString)"
        case let .signpost(signpost): "signpost:\(signpost.id.uuidString)"
        }
    }

    public var tone: BannerTone {
        switch self {
        case .readOnlyLock, .reloadBreakage, .oneShot: .error
        case .historySuspended: .warning
        case .inProgress, .signpost: .info
        }
    }

    /// Whether this row is pinned above the strip's collapse — true for in-progress rows and
    /// nothing else (settled, 02-architecture.md § The banner surface: "a spinner may never hide
    /// behind '+N more'"). The view reads this rather than re-deriving the case.
    public var isPinned: Bool {
        if case .inProgress = self { true } else { false }
    }

    /// The user-facing line. Every word of it comes from `BannerCenter`'s phrasing statics except
    /// an in-progress row's, whose label is its caller's (see `InProgressOperation`).
    public var headline: String {
        switch self {
        case let .readOnlyLock(reason): BannerCenter.headline(for: reason)
        case let .reloadBreakage(error): BannerCenter.headline(for: error)
        case let .oneShot(banner): BannerCenter.headline(for: banner.error)
        case let .historySuspended(suspension): BannerCenter.headline(for: suspension)
        case let .inProgress(operation): operation.label
        case let .signpost(signpost): signpost.message
        }
    }

    /// Only the rows reporting something that already happened carry a dismiss control — one-shot
    /// failures and signposts. Conditions stand until they heal; in-progress rows complete, fail, or
    /// are cancelled — neither is something a user can wave away.
    public var dismissID: UUID? {
        switch self {
        case let .oneShot(banner): banner.id
        case let .signpost(signpost): signpost.id
        case .readOnlyLock, .reloadBreakage, .historySuspended, .inProgress: nil
        }
    }
}

// MARK: - BannerCenter

/// The model behind one window's banner strip: the rows a window owns outright, plus the ordering
/// and phrasing rules the whole app renders through (02-architecture.md § The banner surface).
///
/// ### What lives here and what does not
///
/// A center holds the state nothing else does: dismissable one-shot write failures, the history
/// suspension, in-progress operations, and passive signposts. It deliberately does **not** hold the
/// read-only lock or the reload breakage — those are `BoardStore`'s truths, and copying them here
/// would create a second place for them to be stale. `BoardStore.bannerRows` composes both halves
/// through `rows(lock:breakage:oneShots:suspension:operations:signposts:)`, which is a *pure
/// function* precisely so the precedence rule can be tested without a store, a window, or a
/// filesystem.
///
/// ### Phrasing lives here too
///
/// "The banner owns all user-facing phrasing and localization from that vocabulary" (02 §
/// Write-failure surfacing). `BoardWriteError` carries a closed `WriteOperation` enum and a
/// diagnostic `reason`; `headline(for:)` switches over that enum **exhaustively, with no
/// `default`**, so a Writer operation added without a sentence to say about it is a compile-time
/// hole rather than a silent fallback. The same rule covers locks, breakage, and the history
/// suspension: their user-facing lines are here, not on the error types.
///
/// ### One center per window, not per board
///
/// The strip is "hosted by the window of origin": a card window's own save and attachment failures
/// belong to that window's center, and re-home to the board window's when it closes (m6's job).
/// `BoardStore` owns *the board window's* center, which is why it is a stored `let` there rather
/// than something injected — a board window's strip has exactly one lifetime, the store's.
@MainActor
@Observable
public final class BannerCenter {

    // MARK: State

    /// Newest first, which is the order the class renders in. Kept sorted on insertion rather than
    /// at render time so that two failures sharing a timestamp — plausible inside one run loop
    /// turn — still order deterministically.
    public private(set) var oneShots: [OneShotBanner] = []

    /// The standing "history isn't advancing" condition, or `nil` when commits are landing.
    public private(set) var historySuspension: HistorySuspension?

    /// Work in flight, newest first for the same reason `oneShots` is.
    public private(set) var operations: [InProgressOperation] = []

    /// Passive notices, newest first. Empty until m6's card window starts posting the
    /// remote-change signpost.
    public private(set) var signposts: [InfoSignpost] = []

    public init() {}

    // MARK: One-shots

    /// Records a write failure. **Every failed write lands here before it reaches its caller** —
    /// `BoardStore.performWrite` posts before it rethrows — because the banner is how the one-way
    /// flow stays honest: the action visibly did not happen, and the strip is the only place that
    /// says why (02 § Write-failure surfacing).
    public func post(_ error: BoardWriteError) {
        oneShots.insert(OneShotBanner(error: error), at: 0)
    }

    /// Posts a passive notice — m6's remote-change signpost and whatever joins it. Newest first,
    /// like the one-shots it shares a lifecycle with.
    public func postSignpost(_ message: String) {
        signposts.insert(InfoSignpost(message: message), at: 0)
    }

    /// Removes a dismissable row: a one-shot failure or a signpost. **An id that names an
    /// in-progress operation is ignored** rather than ending it, because "dismiss" and "cancel" are
    /// different promises and a row that offers one must never quietly do the other.
    public func dismiss(_ id: UUID) {
        oneShots.removeAll { $0.id == id }
        signposts.removeAll { $0.id == id }
    }

    /// Removes every dismissable row — one-shots and signposts alike. The strip's own "clear all"
    /// affordance later; today it is what a window uses when it re-homes its rows elsewhere (m6).
    public func dismissAllDismissableRows() {
        oneShots.removeAll()
        signposts.removeAll()
    }

    // MARK: History suspension

    /// Raises (or refreshes) the "changes aren't being recorded to history" condition — m7's
    /// committer calls this when a commit fails past `index.lock` contention (06-history-undo.md
    /// covers the lock itself, which is deliberately *not* a banner).
    ///
    /// A second call while already suspended keeps the original `since` and takes the newer
    /// `reason`: the condition never stopped being true, so restarting its clock would misreport
    /// how long history has been stalled, while the newest diagnosis is the useful one.
    public func suspendHistory(reason: String) {
        historySuspension = HistorySuspension(reason: reason, since: historySuspension?.since ?? Date())
    }

    /// Clears it, on the first successful commit. Idempotent — clearing a condition that is not
    /// standing is not an error, it is the ordinary shape of "commit succeeded".
    public func clearHistorySuspension() {
        historySuspension = nil
    }

    // MARK: In-progress operations

    /// Starts an info row with a spinner and hands back its id.
    ///
    /// - Parameter cancel: non-`nil` only for copy-shaped work, where cancelling means "remove the
    ///   partial copy, nothing lost" (02, settled). Git brackets pass `nil`.
    @discardableResult
    public func beginOperation(label: String, cancel: (@MainActor @Sendable () -> Void)? = nil) -> UUID {
        let operation = InProgressOperation(label: label, cancel: cancel)
        operations.insert(operation, at: 0)
        return operation.id
    }

    /// Ends one — the row leaves the strip.
    ///
    /// **Completion and failure both come through here.** There is no `failOperation`: a failure is
    /// `endOperation(id)` followed by `post(error)`, which is exactly 02's "failure swaps it for
    /// the error row" expressed as two calls that already exist. Ending an unknown id is a no-op;
    /// a close racing a completion is ordinary, not a bug.
    public func endOperation(_ id: UUID) {
        operations.removeAll { $0.id == id }
    }

    // MARK: - Ordering

    /// The strip's rows, precedence-ordered — **the whole of the ordering rule, in one pure
    /// function** (02 § The banner surface, "Concurrent conditions stack").
    ///
    /// The precedence, settled: **in-progress rows (pinned)** > read-only lock > reload breakage >
    /// one-shot write failures > commit and attachment failures > **passive info rows**. Three
    /// readings of it are worth stating because the code depends on them:
    ///
    /// - **The two info classes sit at opposite ends of the strip.** An in-progress row is pinned
    ///   above everything: it is the only explanation the strip offers for a bracket's write lock
    ///   and for a close or quit deferring teardown, and a copy row's Cancel has to stay reachable
    ///   — "a spinner may never hide behind '+N more'". A passive signpost ranks last and may
    ///   collapse: calm by design, nothing gated on seeing it instantly. Same tone, opposite
    ///   urgency.
    /// - **An attachment failure is a one-shot**, not a separate kind — its `WriteOperation` is
    ///   `.importAttachment`. It ranks in the commit-failure class rather than with its fellow
    ///   one-shots, so a fresh failed import sits below an older failed move. That is the design's
    ///   ordering read literally, and it is defensible: a failed import is the least destructive of
    ///   the failures (the drop was accepted, the partial copy was removed), so it yields its place
    ///   to failures that stopped the user's actual work.
    /// - **Within the commit-and-attachment class the standing suspension leads**, then the
    ///   attachment one-shots newest first. A deliberate reading of "newest first within a class":
    ///   a condition and a one-shot are not comparable by recency in any way a user would read as
    ///   order — the condition's `since` is when it *started* being true, not when it happened —
    ///   so they are ordered by kind, and recency orders only the one-shots among themselves.
    ///
    /// `signposts` carries a default because its producer is m6's card window and nothing posts one
    /// today; every other class has a live producer and is spelled out at every call site.
    public nonisolated static func rows(
        lock: ReadOnlyLockReason?,
        breakage: BoardLoadError?,
        oneShots: [OneShotBanner],
        suspension: HistorySuspension?,
        operations: [InProgressOperation],
        signposts: [InfoSignpost] = []
    ) -> [BannerRow] {
        var rows: [BannerRow] = []

        rows.append(contentsOf: operations.map(BannerRow.inProgress))

        if let lock {
            rows.append(.readOnlyLock(lock))
        }
        if let breakage {
            rows.append(.reloadBreakage(breakage))
        }

        let ordered = newestFirst(oneShots)
        rows.append(contentsOf: ordered.lazy.filter { !$0.isAttachmentImport }.map(BannerRow.oneShot))

        if let suspension {
            rows.append(.historySuspended(suspension))
        }
        rows.append(contentsOf: ordered.lazy.filter(\.isAttachmentImport).map(BannerRow.oneShot))

        rows.append(contentsOf: signposts.map(BannerRow.signpost))
        return rows
    }

    /// Newest first, and **stable**: `sorted(by:)` is not, and two failures posted in the same run
    /// loop turn can share a `Date` to the microsecond. Ties fall back to the input order, which
    /// `post(_:)` already maintains newest-first — so a tie renders in the order it was posted
    /// rather than in whatever order the sort happened to leave.
    private nonisolated static func newestFirst(_ banners: [OneShotBanner]) -> [OneShotBanner] {
        banners
            .enumerated()
            .sorted { lhs, rhs in
                lhs.element.occurredAt == rhs.element.occurredAt
                    ? lhs.offset < rhs.offset
                    : lhs.element.occurredAt > rhs.element.occurredAt
            }
            .map(\.element)
    }

    // MARK: - Phrasing

    /// The user-facing line for a failed write: what the app could not do, then why.
    ///
    /// "Couldn't move 'Fix login' — disk full" is the design's own example and the shape every
    /// case takes: an action clause the banner owns, an em dash, and the diagnostic cause from the
    /// error's `reason` — the one place free-form English is allowed to survive.
    public nonisolated static func headline(for error: BoardWriteError) -> String {
        let action = actionPhrase(for: error.operation)
        let cause = causePhrase(for: error.reason)
        return cause.isEmpty ? action : "\(action) — \(cause)"
    }

    /// **Exhaustive by construction — no `default`.** A `WriteOperation` case added without a
    /// sentence here fails to compile, which is the settled contract ("a new Writer operation
    /// without a banner rendering is a compile-time hole, not a silent default").
    ///
    /// Titles are quoted where the operation carries one and the phrasing stays graceful where it
    /// does not: the enum knows an item's title, never its *kind*, so an untitled failure says
    /// "the item" rather than guessing "card" and being wrong about a lane. The trash verbs are
    /// Finder's, matching the commands the user pressed (03-board-ui.md § Trash): Move to Trash,
    /// Put Back, Delete Immediately.
    private nonisolated static func actionPhrase(for operation: WriteOperation) -> String {
        switch operation {
        case .createBoard:
            "Couldn't create the board"
        case .createLane:
            "Couldn't create a lane"
        case .createCard:
            "Couldn't create a card"
        case let .move(title):
            if let title { "Couldn't move '\(title)'" } else { "Couldn't move the item" }
        case let .reorder(title):
            if let title { "Couldn't reorder '\(title)'" } else { "Couldn't reorder the item" }
        case let .copy(title):
            if let title { "Couldn't copy '\(title)'" } else { "Couldn't copy the item" }
        case let .delete(title):
            if let title { "Couldn't move '\(title)' to the trash" } else { "Couldn't move the item to the trash" }
        case let .restore(title):
            if let title { "Couldn't put '\(title)' back" } else { "Couldn't put the item back" }
        case let .purge(title):
            if let title { "Couldn't permanently delete '\(title)'" } else { "Couldn't permanently delete the item" }
        case let .style(title):
            if let title { "Couldn't restyle '\(title)'" } else { "Couldn't restyle the item" }
        case let .importAttachment(filename):
            "Couldn't import '\(filename)'"
        case .listAttachments:
            "Couldn't read this card's attachments"
        case .renumberChildren:
            "Couldn't renumber cards"
        }
    }

    /// The cause tail. `reason`'s own English is diagnostic and already reads as a phrase ("disk
    /// full", "no such file or directory"); the uneditable-frontmatter case is the one that needs
    /// translating, because its `description` is a fragment written for a developer.
    private nonisolated static func causePhrase(for reason: BoardWriteError.Reason) -> String {
        let text = switch reason {
        case let .unreadable(message):
            message
        case let .uneditableFrontmatter(shape):
            "this file's frontmatter can't be edited in place (\(shape.description))"
        case let .io(message):
            message
        }
        return trimmed(text)
    }

    /// The read-only lock's line. Each cause says the same two things — what is wrong, and that
    /// what is on screen is still the last good view — because the lock's whole promise is that
    /// nothing was lost: reading, selecting, searching and copying out all stay live (02 § "The
    /// lock's scope").
    public nonisolated static func headline(for lock: ReadOnlyLockReason) -> String {
        switch lock {
        case .bracketedReloadFailed:
            "This board couldn't be re-read after the last operation — showing the last good view, read-only"
        case .vanishedRoot:
            "This board's folder is gone — showing the last good view, read-only"
        case .unwritableLocation:
            "This board's location can't be written to — showing the last good view, read-only"
        }
    }

    /// Reload breakage: fail-fast's specifics (the offending path and what is wrong with it), plus
    /// the reassurance that the board is still the board.
    ///
    /// The path is root-relative as `BoardLoadError` reports it, and `"."` — the root's own
    /// `index.md` — is spelled as "This board" rather than shown as a lone dot.
    public nonisolated static func headline(for breakage: BoardLoadError) -> String {
        let reason = trimmed(breakage.reason.description)
        let subject = breakage.path == "." || breakage.path.isEmpty
            ? "This board isn't loading"
            : "'\(breakage.path)' isn't loading"
        return "\(subject): \(reason) — showing the last good view"
    }

    /// The suspended-history line. It names the *consequence* the user cares about — undo and the
    /// flush-before-overwrite guarantee are degraded — rather than the git mechanics, and carries
    /// the diagnosis as its tail.
    public nonisolated static func headline(for suspension: HistorySuspension) -> String {
        let reason = trimmed(suspension.reason)
        let line = "Changes aren't being recorded to history"
        return reason.isEmpty ? line : "\(line) — \(reason)"
    }

    /// Trims whitespace and a trailing period: these fragments are tails inside a longer line, and
    /// a stray full stop mid-sentence is the tell of machine-assembled text.
    private nonisolated static func trimmed(_ text: String) -> String {
        var value = text.trimmingCharacters(in: .whitespacesAndNewlines)
        while value.hasSuffix(".") {
            value.removeLast()
        }
        return value
    }
}

// MARK: - Helpers

private extension OneShotBanner {
    /// Whether this failure is an attachment import — the one `WriteOperation` whose one-shot
    /// ranks in the last precedence class rather than with the other one-shots.
    var isAttachmentImport: Bool {
        if case .importAttachment = error.operation { true } else { false }
    }
}
