import AppKit
import SwiftUI

/// The banner strip: one window's standing conditions, unread failures, and work in flight, as a
/// stack of rows (02-architecture.md § The banner surface).
///
/// ### Tones, not components
///
/// There is one row layout and three colorings. An error, a warning, and an info row differ in
/// symbol and tint and in nothing else — which is what lets the card window's remote-change
/// signpost (07-sync-collab.md) be "this same component in the info tone" rather than a second
/// thing that drifts. The per-kind affordances hang off the row's data, not off separate views: a
/// dismiss control appears exactly where `BannerRow.dismissID` is non-`nil`, a spinner and its
/// optional Cancel exactly where the row is `.inProgress`.
///
/// ### The collapse rule
///
/// "Beyond three rows the remainder collapse behind a '+N more' disclosure" — because the strip is
/// window furniture above the board, and a board that has gone badly wrong must not disappear
/// under its own error messages. Precedence ordering is what makes three the right number to show:
/// `BannerCenter.rows(...)` has already put the lock, the breakage, and the newest unread failure
/// at the top, so the collapsed remainder is always the least urgent tail.
///
/// **In-progress rows are exempt, and do not count toward the budget** (settled, 02): they are the
/// strip's only explanation 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'".
/// They are safe to pin because there are few at once and each clears itself. So the strip is
/// always *every* pinned row, then at most three of the rest.
///
/// ### Deliberately self-contained
///
/// It takes rows and two callbacks; it reaches for no store and no environment. The board window
/// (m4) hosts it over the lane area, the card window (m6) hosts its own, and the re-homing rule —
/// a card window's condition moving to the board window's strip when it closes — is a question of
/// which `BannerCenter` a row came from, never of this view.
public struct BannerStripView: View {

    private let rows: [BannerRow]
    private let onDismiss: (UUID) -> Void

    /// How many rows show before the disclosure takes over.
    private static let collapseThreshold = 3

    /// The strip's own rhythm, font-derived like everything else the app lays out
    /// (`BoardMetrics`, 10-accessibility.md ▸ Text scaling: "no fixed point sizes"). It matters here
    /// because a banner row *wraps* rather than truncating — the cause tail is the half that says
    /// what to do — so a fixed inset around growing text crowds fast.
    @MainActor static var rowHorizontalPadding: CGFloat { BoardMetrics.em(0.9, bodyPointSize: pointSize) }
    @MainActor static var rowVerticalPadding: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
    @MainActor static var rowSpacing: CGFloat { BoardMetrics.em(0.6, bodyPointSize: pointSize) }
    @MainActor private static var pointSize: CGFloat { BoardMetrics.bodyPointSize }

    @State private var isExpanded = false

    public init(rows: [BannerRow], onDismiss: @escaping (UUID) -> Void) {
        self.rows = rows
        self.onDismiss = onDismiss
    }

    public var body: some View {
        if !rows.isEmpty {
            VStack(spacing: 1) {
                ForEach(pinnedRows) { row in
                    BannerRowView(row: row, onDismiss: onDismiss)
                }
                ForEach(visibleCollapsibleRows) { row in
                    BannerRowView(row: row, onDismiss: onDismiss)
                }
                if hiddenCount > 0 {
                    disclosure
                }
            }
            .background(.quaternary)
            // **The strip is an accessibility element** (10-accessibility.md ▸ Live board
            // announcements, which makes the live-reload-resilience banner one by name). `.contain`
            // rather than `.combine`: the rows stay individually focusable — each is already one
            // element with its own tone-prefixed label, and a strip that fused three conditions into
            // one utterance would bury the lock under the signposts. The group label is what a
            // VoiceOver user hears on entering it, so "there are conditions here" arrives before the
            // conditions do.
            .accessibilityElement(children: .contain)
            .accessibilityLabel("Board status")
        }
    }

    /// The rows that always show, in precedence order — in-progress rows, which `rows(...)` has
    /// already placed at the head, so rendering them first preserves that order rather than
    /// imposing a second one.
    private var pinnedRows: [BannerRow] {
        rows.filter(\.isPinned)
    }

    private var collapsibleRows: [BannerRow] {
        rows.filter { !$0.isPinned }
    }

    /// Everything collapsible when expanded or short enough, the first three otherwise. `prefix`
    /// and not a filter — precedence order is the whole point, so what shows is always a prefix of
    /// the ordered list.
    private var visibleCollapsibleRows: [BannerRow] {
        isExpanded || collapsibleRows.count <= Self.collapseThreshold
            ? collapsibleRows
            : Array(collapsibleRows.prefix(Self.collapseThreshold))
    }

    private var hiddenCount: Int {
        max(0, collapsibleRows.count - Self.collapseThreshold)
    }

    private var disclosure: some View {
        Button {
            isExpanded.toggle()
        } label: {
            HStack(spacing: BannerStripView.rowSpacing) {
                Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
                    .imageScale(.small)
                Text(isExpanded ? "Show fewer" : "+\(hiddenCount) more")
                Spacer(minLength: 0)
            }
            .font(.callout)
            .padding(.horizontal, BannerStripView.rowHorizontalPadding)
            .padding(.vertical, BannerStripView.rowSpacing)
            .contentShape(Rectangle())
        }
        .buttonStyle(.plain)
        .background(.background.secondary)
    }
}

// MARK: - One row

/// A single banner row. Everything kind-specific is a branch on the row's data; the layout is one
/// `HStack` for all five cases.
private struct BannerRowView: View {

    let row: BannerRow
    let onDismiss: (UUID) -> Void

    var body: some View {
        HStack(alignment: .firstTextBaseline, spacing: BannerStripView.rowVerticalPadding) {
            leading

            Text(row.headline)
                .font(.callout)
                // Wrapping, never truncating: the cause tail ("— disk full") is the half that says
                // what to do about it, and a strip that hid it would be decoration.
                .fixedSize(horizontal: false, vertical: true)
                .frame(maxWidth: .infinity, alignment: .leading)

            trailingControls
        }
        .padding(.horizontal, BannerStripView.rowHorizontalPadding)
        .padding(.vertical, BannerStripView.rowVerticalPadding)
        .frame(maxWidth: .infinity, alignment: .leading)
        .background(row.tone.fill)
        // One element per row, tone included: a VoiceOver user must hear *that* this is an error
        // before hearing what the error is, and colour cannot carry that. The dismiss and Cancel
        // buttons stay inside the combined element, where VoiceOver surfaces them as the row's
        // custom actions — **and are Tab stops in their own right besides** (see `trailingControls`:
        // 10-accessibility.md ▸ Full Keyboard Access, ruled 2026-07-29). The two are independent
        // surfaces, so the combine is unconditional: nothing here is uncombined under FKA, because
        // the focus loop does not need the AX tree's permission to include a button and a VoiceOver
        // user would otherwise hear a different row depending on a keyboard setting.
        //
        // The label is composed by `AccessibilityPhrases` rather than spelled here because the two
        // standing conditions are also *announced* on arrival and clearance (10-accessibility.md ▸
        // Live board announcements, via `BoardAnnouncer`): the sentence a user hears when the lock
        // appears and the sentence they read off the row afterwards are one string, or they are two
        // descriptions of one condition waiting to disagree.
        .accessibilityElement(children: .combine)
        .accessibilityLabel(Text(AccessibilityPhrases.bannerLabel(tone: row.tone, headline: row.headline)))
    }

    @ViewBuilder
    private var leading: some View {
        if case .inProgress = row {
            // The spinner replaces the symbol rather than joining it: an in-progress row's state
            // *is* "still going", and two glyphs saying so would be noise.
            ProgressView()
                .controlSize(.small)
        } else {
            Image(systemName: row.tone.symbolName)
                .foregroundStyle(row.tone.accent)
                .imageScale(.medium)
                .accessibilityHidden(true)
        }
    }

    /// The row's buttons, rendered straight from `BannerRow.controls` — **the Tab loop the FKA
    /// ruling asks for** (10-accessibility.md ▸ Full Keyboard Access, 2026-07-29: "'Every control'
    /// is literal and includes banner-row buttons … 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").
    ///
    /// Driven from the row's inventory rather than from a pair of `if`s over the same data, so what
    /// a row's controls *are* is one testable fact (`BannerCenterTests`) instead of a view detail
    /// that a headless suite cannot see.
    @ViewBuilder
    private var trailingControls: some View {
        ForEach(row.controls) { control in
            button(for: control)
                // **A literal tab stop**, and the focus ring left on to show it — the style editor's
                // wells' rule, for its reason: these are controls, not window furniture, so unlike
                // the board strip (`BoardView`) there is nothing here to suppress. Stated on the
                // button rather than left to the button style, because `.plain` and `.link` render
                // as bare content and the combine above puts them inside another accessibility
                // element: the focus item is asked for outright so neither can quietly cost the row
                // its keyboard reach.
                .focusable()
        }
    }

    @ViewBuilder
    private func button(for control: BannerRowControl) -> some View {
        switch control {
        case let .cancel(cancel):
            // Cancel appears on safe copies only (02, settled): it means "remove the partial copy,
            // nothing lost". Wholesale brackets that pass no closure get no button — once git's,
            // before the excision (`strategy/01-git-excision.md`, 2026-08-08); no caller passes one
            // today.
            Button(control.label, action: cancel)
                .buttonStyle(.link)
                .font(.callout)
        case let .reveal(targets):
            // **One control, two renderings** (`BannerRowControl.reveal`): a sole skipped item gets a
            // plain button, several get a menu naming each path. The row stays one line and one Tab
            // stop either way, which is what keeps the strip's three-row budget intact on the very
            // board that just reported something wrong.
            if targets.count == 1, let only = targets.first {
                Button(control.label) { Self.reveal(only) }
                    .buttonStyle(.link)
                    .font(.callout)
            } else {
                Menu(control.label) {
                    ForEach(targets) { target in
                        Button(target.path) { Self.reveal(target) }
                    }
                }
                .menuStyle(.borderlessButton)
                .fixedSize()
                .font(.callout)
                .accessibilityLabel(control.label)
            }
        case let .dismiss(id):
            Button {
                onDismiss(id)
            } label: {
                Image(systemName: "xmark")
                    .imageScale(.small)
            }
            .buttonStyle(.plain)
            .accessibilityLabel(control.label)
            .help(control.label)
        }
    }

    /// Finder, with the file selected — `activateFileViewerSelecting` rather than `open`, so a
    /// skipped `index.md` is shown *in its folder* rather than handed to whatever app claims `.md`.
    /// The user's next act is looking at where the file sits; opening it is the surface's other
    /// affordance, not this one.
    private static func reveal(_ target: RevealTarget) {
        NSWorkspace.shared.activateFileViewerSelecting([target.url])
    }
}

// MARK: - Tone rendering

private extension BannerTone {
    var symbolName: String {
        switch self {
        case .error: "exclamationmark.triangle.fill"
        case .warning: "exclamationmark.circle.fill"
        case .info: "info.circle.fill"
        }
    }

    var accent: Color {
        switch self {
        case .error: .red
        case .warning: .orange
        case .info: .secondary
        }
    }

    /// A wash, not a slab: the strip sits above the board and must read as furniture rather than as
    /// a second window. Info is deliberately the calmest of the three — "visually calm, no error
    /// colour" is the settled description of the signpost that shares this tone.
    var fill: some ShapeStyle {
        switch self {
        case .error: AnyShapeStyle(Color.red.opacity(0.12))
        case .warning: AnyShapeStyle(Color.orange.opacity(0.12))
        case .info: AnyShapeStyle(.background.secondary)
        }
    }
}

// MARK: - Previews

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

#Preview("Single error") {
    BannerStripView(
        rows: [.oneShot(OneShotBanner(error: previewError(.move(title: "Fix login"))))],
        onDismiss: { _ in }
    )
    .frame(width: 520)
}

#Preview("Stacked tones") {
    BannerStripView(
        rows: [
            .inProgress(InProgressOperation(label: "Pulling…")),
            .readOnlyLock(.vanishedRoot),
            .oneShot(OneShotBanner(error: previewError(.delete(title: "Ship the beta")))),
            .loss(LossBanner(message: "Pasted 'Fix login' without its 3 attachments")),
            .historySuspended(HistorySuspension(reason: "the repository is corrupt")),
            .signpost(InfoSignpost(message: "This card changed on the remote — your edits still win")),
        ],
        onDismiss: { _ in }
    )
    .frame(width: 520)
}

/// A loss row on its own: warning tone, but — unlike the standing `historySuspended` condition
/// beside it in "Stacked tones" — dismissable, since a loss reports something that already
/// happened rather than an ongoing state (settled 2026-07-28, BannerCenter's `LossBanner`).
#Preview("Loss row") {
    BannerStripView(
        rows: [
            .loss(LossBanner(message: BannerCenter.skippedFoldersMessage(count: 2))),
        ],
        onDismiss: { _ in }
    )
    .frame(width: 520)
}

/// Seven rows, one of them pinned: the strip shows the spinner plus the first three of the rest,
/// and "+3 more" counts only what actually collapsed.
#Preview("Collapse") {
    BannerStripView(
        rows: [
            .inProgress(InProgressOperation(label: "Importing 24 attachments…", cancel: {})),
            .readOnlyLock(.bracketedReloadFailed),
            .reloadBreakage(BoardLoadFailure(BoardLoadError(path: "todo/index.md", reason: .missingOrder))),
            .oneShot(OneShotBanner(error: previewError(.move(title: "Fix login")))),
            .oneShot(OneShotBanner(error: previewError(.style(title: "Design review")))),
            .oneShot(OneShotBanner(error: previewError(.renumberChildren))),
            .oneShot(OneShotBanner(error: previewError(.importAttachment(filename: "photo.png"),
                                                      .unreadable(message: "the source file could not be read")))),
        ],
        onDismiss: { _ in }
    )
    .frame(width: 520)
}
