import SwiftUI

// MARK: - BoardLoadingIndicator

/// **The grace before the spinner** — the whole of the pre-snapshot loading state's timing
/// (02-architecture.md § Launch and window lifecycle, ruled 2026-07-29):
///
/// > The content area holds a quiet loading surface: a centered system spinner appearing only after
/// > a short grace (~200 ms) so ordinary fast opens never flash it — no skeleton lanes, the motion
/// > language animates real data only.
///
/// ### Why the grace is an object rather than a `task` modifier
///
/// Two reasons, and they are the same two `DragSession.holdTimeout` has. It is a **state machine**
/// worth pinning — nothing showing before the grace, the spinner after, nothing showing again once
/// the snapshot lands — and a rule about *when* something appears is only checkable if there is
/// something to ask. And the figure has to be injectable: a suite that had to wait the real 200 ms
/// out, once per case, would be paying wall-clock time to assert a constant.
///
/// The body the sleep runs is `graceElapsed()`, spelled as a method rather than inlined in the task,
/// so the "after" half can be pinned directly as well as through the clock — again the drag
/// session's shape (`DragSession.expire`).
///
/// ### It is not a progress report
///
/// The walk has no progress to report — `BoardLoader.load` is one call that either lands or throws
/// — so this is indeterminate by construction and carries no percentage, no phase, and no cancel
/// affordance of its own. ⌘W is the cancel (`BoardWindowHost`), which is 02's own answer.
@MainActor
@Observable
final class BoardLoadingIndicator {

    /// ~200 ms, 02's figure. Named rather than inlined so the one place that states it is the one
    /// place a future ruling would change.
    static let standardGrace: Duration = .milliseconds(200)

    /// Whether the spinner is on screen. False until the grace elapses, and false again the moment
    /// the surface ends — a board that loaded inside the grace never sets it at all, which is the
    /// "ordinary fast opens never flash it" clause.
    private(set) var showsSpinner = false

    /// How long the surface stays empty before the spinner appears. A `var` for `holdTimeout`'s
    /// reason exactly: the app never writes it, and a test that had to wait the real figure out
    /// would be a wall clock standing in for a rule. Not observed — changing it mid-grace is not a
    /// thing that happens.
    @ObservationIgnored var grace: Duration = BoardLoadingIndicator.standardGrace

    @ObservationIgnored private var graceTask: Task<Void, Never>?

    init() {}

    /// Arms the grace. Idempotent: a body that evaluates twice, or a host that configures itself
    /// more than once, must not restart the clock a board has already been waiting on.
    func begin() {
        guard graceTask == nil else { return }
        let grace = self.grace
        graceTask = Task { @MainActor [weak self] in
            try? await Task.sleep(for: grace)
            guard !Task.isCancelled, let self else { return }
            graceElapsed()
        }
    }

    /// The grace's own body: the open is taking long enough to be worth explaining.
    func graceElapsed() {
        showsSpinner = true
    }

    /// The surface is over — the snapshot landed, the walk failed, or ⌘W cancelled it. Disarms the
    /// grace and clears the spinner, so a host that reuses the indicator starts from rest.
    func end() {
        graceTask?.cancel()
        graceTask = nil
        showsSpinner = false
    }
}

// MARK: - BoardLoadingView

/// The board window's content area **before its first snapshot** — quiet by design.
///
/// Nothing but a centered system `ProgressView`, and only once the grace has elapsed. No skeleton
/// lanes (02 rules them out explicitly), no board background — the board's own background colour is
/// a fact of the snapshot that has not arrived, so painting a guess here would be a colour that
/// changed at the snap.
///
/// **The snap is the absence of an animation.** `BoardWindowHost` replaces this view with the board
/// by assigning its phase outside any `withAnimation`, per 02's "the first snapshot replaces the
/// surface in place (a snap — there is no prior arrangement to animate from)" and the Motion
/// vocabulary's reload seam. There is deliberately no transition on this view.
struct BoardLoadingView: View {

    let indicator: BoardLoadingIndicator

    var body: some View {
        ZStack {
            if indicator.showsSpinner {
                ProgressView()
                    .controlSize(.large)
                    .accessibilityLabel(AccessibilityPhrases.boardLoading)
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }
}
