import CoreGraphics

/// The board strip's geometry, as pure arithmetic — no view, no window, no state
/// (`LaneLayoutMathTests`).
///
/// Two rules from 03-board-ui.md meet here, and they are deliberately *different* mechanisms
/// sharing one set of numbers:
///
/// - **Full visibility** (§ Layout — full visibility): the window's width divides across the lanes'
///   width units, so `standardWidth` is the whole of the resting layout. There is no horizontal
///   scroll and no minimum lane width to honour — enough units in a small window compress every
///   lane, and that is accepted rather than floored.
/// - **The right-edge drag** (§ Lane): a snap between whole unit counts that grows or shrinks the
///   *window* by one standard width per tick, so the other lanes keep their exact pixels.
///   `slotWidth`, `snappedUnits`, `resistedWidth` and `maxUnits` are that interaction's arithmetic,
///   ported from the pathfinder's proven `ColumnResizeMath` (its reasoning is reproduced below,
///   since the behaviour is what was proven, not the code).
///
/// The one behavioural difference from the pathfinder: **Lanework has no upper width cap.** A lane
/// spans any whole number of units ≥ 1, so `allowedRange`'s ceiling is only ever the on-screen fit
/// (`maxUnits`) — there is no `Column.widthRange` equivalent to fold in, and shrinking is always
/// allowed.
enum LaneLayoutMath {

    // MARK: - The resting layout

    /// The 1× (one unit) lane width for a strip `stripWidth` points wide laying out `totalUnits`
    /// whole units with `gap` between lanes **and `gap` again outside the first and the last** —
    /// hence `totalUnits + 1` gaps: the `totalUnits - 1` interior ones plus the strip's two outer
    /// margins. The strip therefore always exactly fills, which is what "every lane is always on
    /// screen" means arithmetically (03-board-ui.md § Layout — full visibility).
    ///
    /// **Floored at 1pt, and at nothing else.** The design is explicit that the degenerate case is
    /// accepted, not floored: a minimum lane width would reintroduce horizontal scroll, which was
    /// considered in the pathfinder and deliberately rejected. The 1pt floor exists only so a frame
    /// is never zero or negative — the pathological input (a strip narrower than its own gaps) must
    /// not produce a negative size for SwiftUI to complain about.
    static func standardWidth(stripWidth: CGFloat, totalUnits: Int, gap: CGFloat) -> CGFloat {
        let count = CGFloat(max(1, totalUnits))
        return max(1, (stripWidth - gap * (count + 1)) / count)
    }

    /// The rendered width of a `units`-unit lane: `units` standard widths plus the `units - 1`
    /// interior gaps it swallows. `BoardView`'s per-lane frame is this exact expression, so a
    /// snapped slot and a committed lane are the same pixels.
    static func slotWidth(units: Int, standard: CGFloat, gap: CGFloat) -> CGFloat {
        standard * CGFloat(units) + gap * CGFloat(units - 1)
    }

    /// The whole units a lane spans on screen: its `width` when that read as a valid integer, 1
    /// otherwise.
    ///
    /// `Lane.width` is a **lenient** field (01-storage-format.md § Frontmatter): a missing key or
    /// a non-numeric/fractional value arrives here as `.missing` or `.malformed` and renders as
    /// one unit, while an exact-integer reading below 1 (zero, negative) is no longer malformed at
    /// all — it coerces to 1 at the read side (**ranges are part of the sensible reading**,
    /// settled). Either way the bytes on disk are left exactly as the author wrote them until the
    /// user actually changes the width, at which point the Writer replaces them with an integer
    /// (`BoardStore.setLaneWidth`). The `max(1,)` is belt over braces: the read side already never
    /// produces anything below 1, and this function is the single place the rest of the UI asks
    /// "how many units does this lane span".
    static func displayUnits(of lane: Lane) -> Int {
        max(1, lane.width.value ?? 1)
    }

    /// The unit total a strip of `lanes` divides across — the sum of their display units, never
    /// below 1 so `standardWidth` cannot be handed a zero divisor for an empty board.
    ///
    /// The caller decides *which* lanes: the strip passes the snapshot's, in order. There is no
    /// liveness question left to ask — "Cards only. Lanes are never trashed" (03-board-ui.md §
    /// Trash), so every lane the snapshot holds is a lane on screen consuming its units.
    ///
    /// **`trashUnits` is the trash column's fixed one unit, and it is *only* consumed while shown**
    /// (03-board-ui.md § Trash): the trash "spans a fixed one width unit — no `width` frontmatter,
    /// and neither the stepper nor the edge drag applies — consumed only while shown". Passing it
    /// here rather than fabricating a `Lane` for the trash is what keeps that true: there is no lane
    /// value anywhere that a reorder, a resize or a width write could reach.
    ///
    /// Show/Hide Trash is therefore a **re-divide trigger** and nothing more — the window is
    /// untouched, and the existing width divides across one more (or one fewer) unit, exactly as a
    /// lane add does (§ Layout — full visibility).
    static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
        max(1, lanes.reduce(0) { $0 + displayUnits(of: $1) } + max(0, trashUnits))
    }

    // MARK: - Hit testing

    /// Which lane sits under `x` in strip coordinates (0 at the strip's leading edge, outer margin
    /// included) — an index into `unitCounts`, or `nil` when `x` is not over a lane at all.
    ///
    /// **The gaps and the margins answer `nil` deliberately**, and so does everything past the last
    /// lane — which is where the trash column sits. That is the whole of drag-to-restore's
    /// "a drop anywhere else is a no-op" (03-board-ui.md § Trash): a drop that does not land
    /// squarely on a live lane writes nothing rather than guessing at the nearest one.
    ///
    /// Same analytic geometry as `DropSlotMath.laneExtents` — resting positions computed from
    /// the unit counts, never measured frames (03-board-ui.md § Motion, "motion never feeds back
    /// into logic").
    static func laneIndex(atX x: CGFloat, unitCounts: [Int], standard: CGFloat, gap: CGFloat) -> Int? {
        var left = gap
        for (index, units) in unitCounts.enumerated() {
            let width = slotWidth(units: units, standard: standard, gap: gap)
            if x >= left, x < left + width { return index }
            left += width + gap
        }
        return nil
    }

    // MARK: - The drag's snap

    /// The snapped unit count after a live-width change: `currentUnits` unless the live width has
    /// moved far enough into "shadow leads" territory for an adjacent slot, in which case it ticks
    /// by exactly one.
    ///
    /// This is an ASYMMETRIC snap, not a midpoint-±-band around a boundary: the shadow (the snapped
    /// count, which sizes the visible slot) leads the live edge going up and deliberately lags it
    /// coming back down.
    ///
    ///   • Tick UP fires the instant the live edge clears the FAR side of the gap that trails slot
    ///     `k` — `liveWidth > slotWidth(k) + gap` — which is exactly where the next lane's content
    ///     would start. The moment the cursor has eaten the whole gap, the bigger slot is already
    ///     the honest read of what is under it, so the shadow jumps there right away: it never
    ///     lags, and the live edge can only ever overhang the shadow by at most one gap width,
    ///     transiently, in the instant just before a tick.
    ///   • Tick DOWN fires only once the live edge has retreated `reentry` (10pt, fixed) back INTO
    ///     that same gap — `liveWidth < slotWidth(k - 1) + gap - reentry` — rather than at the
    ///     mirror image of the tick-up threshold. Shrinking back the instant the edge re-enters the
    ///     gap it just cleared would flap the window on the smallest jitter right at the crossing;
    ///     requiring a real 10pt of retreat means the cursor has to mean it. (03-board-ui.md § Lane
    ///     names exactly this: "shadow snaps at the inter-column gap with 10pt release hysteresis".)
    ///
    /// `reentry` is the ONLY hysteresis in this design — it exists to give the tick-down threshold
    /// room, not to make the two thresholds symmetric. Stability follows from the two thresholds
    /// never meeting: for shadow `k` the hold band is `(slotWidth(k - 1) + gap - reentry,
    /// slotWidth(k) + gap]`, which stays non-empty as long as `standard` is many times larger than
    /// 10pt (true of every lane width a real window produces), so calling this on every drag event
    /// never oscillates.
    ///
    /// Ticks are capped to `allowedRange`, which in Lanework folds in **only** the on-screen fit
    /// (`maxUnits`) — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no
    /// cap"), and the uncapped widths beyond the screen's capacity are the stepper's business, not
    /// the drag's.
    static func snappedUnits(
        liveWidth: CGFloat,
        currentUnits: Int,
        standard: CGFloat,
        gap: CGFloat,
        allowedRange: ClosedRange<Int>,
        reentry: CGFloat
    ) -> Int {
        let currentSlot = slotWidth(units: currentUnits, standard: standard, gap: gap)
        if currentUnits < allowedRange.upperBound, liveWidth > currentSlot + gap {
            return currentUnits + 1
        }
        if currentUnits > allowedRange.lowerBound {
            let previousSlot = slotWidth(units: currentUnits - 1, standard: standard, gap: gap)
            if liveWidth < previousSlot + gap - reentry {
                return currentUnits - 1
            }
        }
        return currentUnits
    }

    /// The live width rubber-banded to stay near the allowed slot range: inside `[minSlot,
    /// maxSlot]` the proposed width passes through untouched; beyond either end only `resistance`
    /// (0.25) of the overshoot is applied, so the edge visibly resists but still gives, signalling
    /// the bound without a hard stop (03-board-ui.md § Lane: "Growth hard-stops at the screen's
    /// visible frame, with rubber-band feedback"). The snap tick never follows the width past the
    /// bound (see `snappedUnits`' clamp), so this is purely cosmetic give.
    static func resistedWidth(
        proposed: CGFloat,
        minSlot: CGFloat,
        maxSlot: CGFloat,
        resistance: CGFloat
    ) -> CGFloat {
        if proposed < minSlot { return minSlot - (minSlot - proposed) * resistance }
        if proposed > maxSlot { return maxSlot + (proposed - maxSlot) * resistance }
        return proposed
    }

    /// The largest unit count that fits on screen: `currentUnits` plus as many whole `step`
    /// (= standard + gap) growths as the window has room to expand into before its right edge would
    /// pass the screen's visible frame. **Never less than `currentUnits`** — shrinking is always
    /// allowed regardless of screen room, including from a window already hanging off the edge
    /// (negative headroom).
    ///
    /// Unlike the pathfinder's twin there is no width ceiling to `min` against: the drag's only
    /// bound is the screen, because it is the mechanism that grows the window. Larger widths are
    /// reachable through the stepper, which re-divides instead (03-board-ui.md § Lane).
    ///
    /// Pure so it can be unit-tested; the session computes `headroom` from the live window and its
    /// screen and defers the arithmetic here.
    static func maxUnits(currentUnits: Int, headroom: CGFloat, step: CGFloat) -> Int {
        guard step > 0, headroom.isFinite else { return currentUnits }
        let extra = Int(floor(max(0, min(headroom, CGFloat(Int.max) / 2)) / step))
        return max(currentUnits, currentUnits + extra)
    }
}
