import CoreGraphics
import Foundation
import Observation

/// The board's zoom level: one number, app-wide, persisted (11-command-nexus.md ▸ View ▸ Actual Size
/// — "app-wide and persisted across restarts"; 03-board-ui.md ▸ Layout — zoom).
///
/// `StyleRecents`' shape exactly, for its reasons. The *rules* — what the rungs are, how stepping
/// terminates, what an illegal stored value becomes — are `BoardZoom`'s pure functions; this object is
/// their persistence and their observability, and `defaults` is injectable so a test drives a suite of
/// its own rather than the user's.
///
/// ### Why app-wide, and not per board
///
/// A zoom level describes how this user likes to read, not what a board is. It has no business in a
/// lane's frontmatter (where `width` lives — that *is* board data, shared with every collaborator and
/// every agent), and no business in `BoardRegistry` either: a board opened on a laptop and on a studio
/// display wants the same *preference* applied, not a per-board memory of a window that no longer
/// exists. It sits beside Show Comments and Comments Beside Body, which are app-wide and persisted for
/// the same reason.
///
/// ### Why `@Observable` rather than `@AppStorage`
///
/// Two consumers need change notification that a property wrapper in a view cannot give them. The
/// board toolbar's validation is *observed*, not polled — `WindowToolbarController.trackValidationState`
/// re-arms `withObservationTracking` over each spec's `isEnabled`, so Zoom In greys out at the top rung
/// only if the level it reads is observable. And menu commands live outside every scene's environment,
/// so they receive `AppModel` as a plain `let` and re-render only because it is `@Observable`. One
/// observable holder serves both, and the level has exactly one home rather than a mirror per surface.
@MainActor
@Observable
public final class BoardZoomStore {

    /// The current rung — always a member of `BoardZoom.levels`, guaranteed by construction: the
    /// initialiser normalises what it reads and `setLevel` normalises what it is given, so no reader
    /// anywhere has to ask whether its level is legal.
    public private(set) var level: CGFloat

    @ObservationIgnored
    private let defaults: UserDefaults

    /// - Parameter defaults: the domain to persist in. Injected for `StyleRecents`' reason — a test
    ///   must be able to hold its own without touching the user's.
    public init(defaults: UserDefaults = .standard) {
        self.defaults = defaults
        // `object(forKey:)` rather than `double(forKey:)` so an unset key arrives as nil rather than as
        // 0, and `normalize` rather than `?? 1.0` so a hand-edited or stale value lands on a rung too.
        // Either way the answer is legal; the distinction only decides *which* legal value an absent
        // key becomes, and both roads lead to Actual Size.
        level = BoardZoom.normalize((defaults.object(forKey: AppPreferences.boardZoomLevelKey) as? Double) ?? Double(BoardZoom.actualSize))
    }

    // MARK: - Moving

    /// Sets the level to the nearest legal rung and persists it.
    ///
    /// The single write path — Zoom In, Zoom Out, Actual Size and the two toolbar buttons all arrive
    /// here, which is what keeps the menu row and its toolbar twin from being two implementations of
    /// one command (the `BoardStore.setTrashVisible` precedent).
    ///
    /// **An unchanged level writes nothing and publishes nothing**, and unlike `StyleRecents.record`'s
    /// unconditional write that guard is load-bearing rather than an optimisation. `@Observable`
    /// notifies on *every* set, equal or not, so an ungated assignment would invalidate the board
    /// window's zoom environment on a no-op — Actual Size when already at 100%, ⌘+ at the top rung —
    /// and re-run the whole strip to draw exactly what it was drawing. A board nobody zoomed must not
    /// pay for the feature existing.
    ///
    /// The *announcement* is deliberately not gated with it (`step(_:)`): a user who pressed ⌘+ at the
    /// top rung is still owed the answer.
    public func setLevel(_ newValue: CGFloat) {
        let normalized = BoardZoom.normalize(Double(newValue))
        guard normalized != level else { return }
        level = normalized
        defaults.set(Double(normalized), forKey: AppPreferences.boardZoomLevelKey)
    }

    /// One rung up; a fixed point at the top.
    public func zoomIn() { setLevel(BoardZoom.stepIn(from: level)) }

    /// One rung down; a fixed point at the bottom.
    public func zoomOut() { setLevel(BoardZoom.stepOut(from: level)) }

    /// Back to 100%, where the strip renders exactly what it rendered before zoom existed.
    public func actualSize() { setLevel(BoardZoom.actualSize) }

    // MARK: - Reading

    public var canZoomIn: Bool { BoardZoom.canZoomIn(level) }

    public var canZoomOut: Bool { BoardZoom.canZoomOut(level) }

    public var isActualSize: Bool { BoardZoom.isActualSize(level) }

    /// The level as the strip's views take it.
    ///
    /// Internal rather than `public` like its neighbours, and the asymmetry is the type's, not an
    /// oversight: `BoardZoomContext` is an environment value, which is a UI-layer concern the way
    /// `dragSession` is — nothing outside this module has any business rendering a board.
    var context: BoardZoomContext { BoardZoomContext(level: level) }

    /// "125%" — the announcement's value (10-accessibility.md ▸ Text scaling).
    public var percentLabel: String { BoardZoom.percentLabel(level) }
}
