import AppKit
import CoreGraphics
import SwiftUI

/// The board's **zoom ladder** — the multiplier macOS never supplies, applied to the one scalar the
/// whole strip is already derived from (03-board-ui.md ▸ Layout — "Zoom scales the ruler, never the
/// strip"; 10-accessibility.md ▸ Text scaling).
///
/// `Motion` and `Accommodations`' sibling, and deliberately the same shape: pure functions over a
/// value, nothing rendered, nothing stored. `BoardZoomStore` owns the level and persists it; this
/// owns what a level *means*, so every claim below is assertable without driving a view
/// (`BoardZoomTests`).
///
/// ### Why this feature is arithmetic rather than a magnification
///
/// `BoardMetrics` is twenty-two `em(multiple, bodyPointSize:)` functions and one impure read of what
/// the body point size is, because 10-accessibility.md committed the board to full relative scaling:
/// "relative text styles everywhere, no fixed point sizes … so layout survives the largest system
/// text sizes". On macOS that commitment has never once been exercised — there is no system text-size
/// setting, so `NSFont.preferredFont(forTextStyle: .body).pointSize` is 13 forever. Zoom is that
/// machinery's first consumer: a rung on the ladder multiplies the point size, and the twenty-two
/// functions carry it to every gap, radius, inset and stripe on the board.
///
/// A `scaleEffect` or an `NSScrollView.magnification` was the other candidate and is rejected on two
/// counts. It would have to widen the strip, which means horizontal scroll — refused by
/// 03-board-ui.md ▸ Layout, and refused in the pathfinder before it. And it would scale the
/// coordinate space that `onGeometryChange` reports frames in while leaving `NSEvent.mouseLocation`
/// alone, silently desynchronising every drop registry from the cursor.
///
/// ### What the level does not touch
///
/// **Lane width.** The window's width divides across the lanes' width units and that is the whole of
/// the resting layout (`LaneLayoutMath.standardWidth`), so a rung moves the inter-lane gap — an em
/// multiple like everything else — and nothing more. Lanes narrow by a few percent across the
/// ladder's whole range; cards inside them grow. That asymmetry *is* the feature on a board that
/// refuses horizontal scroll: zoom in for bigger, more legible cards and fewer per screen.
///
/// **The window.** `BoardMetrics.windowMinimumSize` stays pinned to the system body size, because
/// 03-board-ui.md ▸ Lane rules that moving the window belongs to the right-edge drag alone.
enum BoardZoom {

    // MARK: - The ladder

    /// The rungs, ascending. Zoom In and Zoom Out step between adjacent entries; there is no
    /// continuous level, and nothing off this list is reachable through the UI.
    ///
    /// Discrete rather than a percentage field for the reason every zoom control on the platform is:
    /// the interesting question is "a bit bigger", not "117%", and a ladder makes ⌘+ ⌘+ ⌘− land back
    /// exactly where it started. The spacing widens as it climbs — 0.10 near the default, 0.25 at the
    /// top — because a fixed step reads as too coarse small and too fine large.
    ///
    /// The floor is 0.75 (≈10pt body text) rather than something smaller: below that the card face's
    /// title stops being readable at arm's length and the board stops being a board. The ceiling is
    /// 2.0, where a standard lane still fits a card with room for its title.
    static let levels: [CGFloat] = [0.75, 0.85, 1.0, 1.15, 1.3, 1.5, 1.75, 2.0]

    /// The rung Actual Size returns to, and the one every metric on the board was tuned against.
    ///
    /// **The default renders pixel-for-pixel what it rendered before zoom existed** — `bodyPointSize`
    /// below multiplies by exactly 1 and `font` hands back the relative style untouched. That is the
    /// same guarantee `BoardMetrics`' em multiples were chosen under ("this milestone is meant to make
    /// the board *scale*, not to redesign it"), extended to the control that moves them.
    static let actualSize: CGFloat = 1.0

    /// The next rung up, or `level` itself at the top — a fixed point rather than a wrap, so a held
    /// ⌘+ settles instead of cycling.
    static func stepIn(from level: CGFloat) -> CGFloat {
        levels.first { $0 > level } ?? levels[levels.count - 1]
    }

    /// The next rung down, or `level` itself at the bottom.
    static func stepOut(from level: CGFloat) -> CGFloat {
        levels.last { $0 < level } ?? levels[0]
    }

    static func canZoomIn(_ level: CGFloat) -> Bool { level < levels[levels.count - 1] }

    static func canZoomOut(_ level: CGFloat) -> Bool { level > levels[0] }

    /// Whether Actual Size would change anything.
    static func isActualSize(_ level: CGFloat) -> Bool { level == actualSize }

    // MARK: - Reading a stored level

    /// An arbitrary stored `Double` mapped onto a legal rung — clamped to the ladder's ends and
    /// snapped to the nearest entry.
    ///
    /// Every read of the persisted level goes through this, and it is not defensive decoration. The
    /// preference is a plain `UserDefaults` key: a user can `defaults write` it to anything, a future
    /// build can shorten the ladder under a value written by an older one, and `double(forKey:)`
    /// answers **0 for a key that was never set** — which, unfiltered, would drive every em multiple
    /// to its 1pt floor and draw a board of hairlines. Nothing downstream should have to ask whether
    /// its level is real.
    ///
    /// Non-finite input (NaN, infinity) resolves to `actualSize` rather than to an end of the ladder:
    /// there is no honest nearest rung to a value that is not a number, and the default is the only
    /// answer that cannot surprise.
    static func normalize(_ stored: Double) -> CGFloat {
        guard stored.isFinite else { return actualSize }
        let value = CGFloat(stored)
        guard let nearest = levels.min(by: { abs($0 - value) < abs($1 - value) }) else { return actualSize }
        return nearest
    }

    // MARK: - What a level means

    /// The body point size the board draws at: the system's, times the level.
    ///
    /// **Deliberately not rounded.** `BoardMetrics.em` already rounds every figure it produces to a
    /// whole point and floors it at one, so rounding here would round twice — and the two roundings
    /// disagree (`round(round(13 × 1.15) × 0.9)` is not `round(13 × 1.15 × 0.9)`). One rule, applied
    /// where it already lives: the level scales, `em` rounds.
    ///
    /// Floored at 1 for the same reason `em` is: no proposal downstream may be zero or negative, and
    /// a level cannot be trusted to be positive until `normalize` has seen it.
    static func bodyPointSize(system: CGFloat, level: CGFloat) -> CGFloat {
        max(1, system * level)
    }

    /// A text style at this level — **the relative style itself at Actual Size**, a fixed size scaled
    /// off it anywhere else.
    ///
    /// The early return is the whole reason this function exists rather than an unconditional
    /// `.system(size:)`. A relative style carries more than a number — the system's own leading and
    /// its accessibility traits ride along with it — and a user who never touches zoom should not pay
    /// for the feature by having every label on the board silently swapped for a point size. So the
    /// default rung keeps the semantic style verbatim and only a deliberate zoom trades it away.
    ///
    /// Off the default, every style scales by the same factor, so the type hierarchy the board
    /// encodes — `.headline` over `.body` over `.caption` — survives the trip intact.
    @MainActor
    static func font(_ style: Font.TextStyle, level: CGFloat, weight: Font.Weight? = nil) -> Font {
        guard level != actualSize else { return .system(style, weight: weight) }
        let system = NSFont.preferredFont(forTextStyle: style.appKitStyle).pointSize
        return .system(size: system * level, weight: weight)
    }

    /// The level as a percentage, for the announcement a zoom owes VoiceOver
    /// (10-accessibility.md ▸ Text scaling).
    static func percentLabel(_ level: CGFloat) -> String {
        "\(Int((level * 100).rounded()))%"
    }
}

// MARK: - The environment

/// The zoom level as the strip's views see it — **the app's one custom environment value**.
///
/// The environment rather than a read of the store inside each `body`, and the reason is specific
/// enough to be worth stating: `CardFaceView` and `LaneView` are `.equatable()`, and
/// `CardFaceView`'s own note says `@Environment` values are "deliberately NOT compared" because
/// "SwiftUI invalidates on those itself". A level threaded through the environment therefore
/// propagates *through* the render gates for free. A level read from a singleton or mirrored into
/// `@State` would be swallowed by them on every card face — the board would zoom everywhere except
/// the cards.
///
/// The value carries the level rather than the multiplied point size so it stays `Sendable` and
/// non-isolated (the environment's default must be constructible without touching AppKit); the
/// system half is read on demand below, exactly as `BoardMetrics.bodyPointSize` reads it.
struct BoardZoomContext: Equatable, Sendable {

    var level: CGFloat

    /// The unzoomed board — the environment's default, and what any view rendered outside a board
    /// window's injection gets. Unzoomed is the only honest fallback: it is what the board drew
    /// before this file existed.
    static let actualSize = BoardZoomContext(level: BoardZoom.actualSize)

    /// The number every `BoardMetrics` function takes.
    ///
    /// **This, not `BoardMetrics.bodyPointSize`, is what the strip asks.** The two are deliberately
    /// different readings and the distinction is load-bearing: `BoardMetrics.bodyPointSize` is the
    /// *system's* body size, which is still what the window minimum, the banner strip, the sheets and
    /// popovers, the welcome window and the whole card window want — none of them zoom (03-board-ui.md
    /// ▸ Layout: the level is the board's, and a sheet is a form, not the board).
    @MainActor
    var bodyPointSize: CGFloat {
        BoardZoom.bodyPointSize(system: BoardMetrics.bodyPointSize, level: level)
    }

    @MainActor
    func font(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> Font {
        BoardZoom.font(style, level: level, weight: weight)
    }
}

extension EnvironmentValues {
    /// Injected once, on the board window's content (`BoardWindowHost`). Everything under it — lanes,
    /// card faces, the trash column, drag shadows, the resize handle — reads its ruler from here.
    @Entry var boardZoom: BoardZoomContext = .actualSize
}

extension View {

    /// A text style at the board's current zoom — the strip's replacement for `.font(_:)`.
    ///
    /// Every `Text` inside the strip wears this instead of a bare relative style, because the style
    /// alone does not move: SwiftUI resolves `.caption` against the system, which zoom does not
    /// change. Chrome outside the strip keeps using `.font(_:)` and stays at system size on purpose.
    ///
    /// SF Symbols need no equivalent — `Image(systemName:)` under `.imageScale(_:)` sizes off the
    /// current font, which is why `BoardMetrics.laneCollapseButtonReserve` is 1.4 em rather than 18pt.
    func boardFont(_ style: Font.TextStyle, weight: Font.Weight? = nil) -> some View {
        modifier(BoardFontModifier(style: style, weight: weight))
    }
}

private struct BoardFontModifier: ViewModifier {

    @Environment(\.boardZoom) private var zoom

    let style: Font.TextStyle
    let weight: Font.Weight?

    func body(content: Content) -> some View {
        content.font(zoom.font(style, weight: weight))
    }
}

// MARK: - The style tables

private extension Font.TextStyle {

    /// SwiftUI's text style as AppKit names it, so `NSFont.preferredFont(forTextStyle:)` can be asked
    /// what point size it resolves to.
    ///
    /// A table rather than a rawValue bridge because there is no bridge: the two enumerations agree on
    /// most names and disagree on three — SwiftUI's `.title` is AppKit's `.title1`, its `.caption` is
    /// `.caption1`, and the sizes SwiftUI adds beyond AppKit's list have nowhere to land. Those fall
    /// back to `.body`, which is the closest AppKit *has*; none of them appears on the board.
    var appKitStyle: NSFont.TextStyle {
        switch self {
        case .largeTitle: .largeTitle
        case .title: .title1
        case .title2: .title2
        case .title3: .title3
        case .headline: .headline
        case .subheadline: .subheadline
        case .body: .body
        case .callout: .callout
        case .footnote: .footnote
        case .caption: .caption1
        case .caption2: .caption2
        @unknown default: .body
        }
    }
}
