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 two mechanisms meet at the screen's visible frame** (settled 2026-08-08, § Lane): once the
/// window can grow no further the drag stops moving it and degrades to the re-divide — the same
/// fixed width across one more unit per tick, siblings compressing, which is precisely what the
/// stepper does. `pinnedStripWidth`, `resizeStandard`, `resizeMaxUnits` and `resizeWindowDelta` are
/// that second regime; they are written so the boundary itself costs no pixels, since the re-divided
/// standard at the screen fit *is* the frozen standard.
///
/// 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 not a width range but the
/// strip's own capacity (`resizeMaxUnits`) — 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.
    ///
    /// **Collapsed lanes are taken off the top, never divided** (03-board-ui.md § Lane ▸ Collapsed
    /// lanes): each one consumes a fixed `collapsedWidth` plus the gap that follows it, and the
    /// remainder is what the expanded lanes' `totalUnits` divide. So a strip of `T` units and `C`
    /// collapsed strips still fills exactly — `C·collapsedWidth + T·standard + (T + C + 1)·gap` — and
    /// `collapsedCount * (collapsedWidth + gap)` is that identity rearranged, which is why the
    /// subtraction carries a gap with it.
    ///
    /// Both extra arguments default to nothing, so a board with no folded lane reads exactly as it did
    /// before they existed — and so does every call site that has no collapse question to ask (the
    /// resize drag's two regimes, whose `startTotalUnits` is already the expanded total).
    static func standardWidth(
        stripWidth: CGFloat,
        totalUnits: Int,
        gap: CGFloat,
        collapsedCount: Int = 0,
        collapsedWidth: CGFloat = 0
    ) -> CGFloat {
        let count = CGFloat(max(1, totalUnits))
        let folded = CGFloat(max(0, collapsedCount)) * (collapsedWidth + gap)
        return max(1, (stripWidth - folded - 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)
    }

    /// **Whether the lane is drawn as a slim strip** (03-board-ui.md § Lane ▸ Collapsed lanes) — the
    /// one place the rest of the app asks, so the leniency below is stated once.
    ///
    /// `collapsed` is a **lenient** field exactly like `width` (01-storage-format.md § Frontmatter): a
    /// missing key, an explicit `false`, and a value with no boolean reading at all (`.malformed`) are
    /// one answer here — **expanded** — with the author's bytes left alone either way. Only a real
    /// `true` folds a lane, which is what makes an unreadable value harmless rather than surprising.
    ///
    /// A collapsed lane keeps its `width` untouched (`BoardStore.setLaneCollapsed` writes one key and
    /// only one), so `displayUnits` still answers for it — and is deliberately still asked, by the
    /// interior masonry the lane will draw again the moment it expands.
    static func isCollapsed(_ lane: Lane) -> Bool {
        lane.collapsed.value == true
    }

    /// How many of `lanes` are drawn as slim strips — the count `standardWidth` takes off the top.
    static func collapsedCount(of lanes: [Lane]) -> Int {
        lanes.count { isCollapsed($0) }
    }

    /// A lane's **drawn** width: its slot width when expanded, the fixed strip when collapsed. The one
    /// answer every hit test and every zone list is built from, so the arithmetic can never disagree
    /// with what the strip laid out (`BoardView.laneSlot` frames each lane with this).
    static func drawnWidth(
        units: Int,
        isCollapsed: Bool,
        standard: CGFloat,
        gap: CGFloat,
        collapsedWidth: CGFloat
    ) -> CGFloat {
        isCollapsed ? collapsedWidth : slotWidth(units: units, standard: standard, gap: gap)
    }

    /// `drawnWidth` over a run of lanes, in the order given — the widths list the strip's zones and
    /// hit tests walk (`DropSlotMath.laneExtents`, `laneIndex(atX:widths:gap:)`).
    static func drawnWidths(
        of lanes: [Lane],
        standard: CGFloat,
        gap: CGFloat,
        collapsedWidth: CGFloat
    ) -> [CGFloat] {
        lanes.map {
            drawnWidth(
                units: displayUnits(of: $0),
                isCollapsed: isCollapsed($0),
                standard: standard,
                gap: gap,
                collapsedWidth: collapsedWidth)
        }
    }

    /// `drawnWidth` over a **dragged** run, whose fold state travels beside its unit counts rather than
    /// inside a `Lane` (`DragSession.laneUnits` / `laneCollapsed`, both frozen at pickup).
    ///
    /// A `collapsed` array shorter than `units` reads as expanded past its end, which is what a
    /// `beginLanes` caller that passed none means — and the trashed-lane restore is exactly that caller.
    static func drawnWidths(
        units: [Int],
        collapsed: [Bool],
        standard: CGFloat,
        gap: CGFloat,
        collapsedWidth: CGFloat
    ) -> [CGFloat] {
        units.enumerated().map { index, units in
            drawnWidth(
                units: units,
                isCollapsed: index < collapsed.count && collapsed[index],
                standard: standard,
                gap: gap,
                collapsedWidth: collapsedWidth)
        }
    }

    /// The unit total a strip of `lanes` divides across — the sum of the **expanded** lanes' 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).
    ///
    /// **A collapsed lane contributes nothing** (03-board-ui.md § Lane ▸ Collapsed lanes: "the strip
    /// is not part of the width re-division"): its fixed width is `standardWidth`'s subtraction, not a
    /// share of the division, so folding a lane away is a re-divide trigger of the same family — the
    /// window is untouched and the siblings grow into the space the lane gave up.
    ///
    /// The `max(1,)` therefore covers one more shape than it used to: a board whose **every** lane is
    /// collapsed has no expanded unit at all, and the 1 it answers is a divisor guard rather than a
    /// description of anything on screen. Nothing draws with that standard — the strips take their
    /// fixed width and the leftover is empty board — except a shown trash column, which is a real unit
    /// in the total and correctly gets the whole remainder.
    static func totalUnits(of lanes: [Lane], trashUnits: Int = 0) -> Int {
        let units = lanes.reduce(0) { $0 + (isCollapsed($1) ? 0 : displayUnits(of: $1)) }
        return max(1, units + 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? {
        laneIndex(
            atX: x,
            widths: unitCounts.map { slotWidth(units: $0, standard: standard, gap: gap) },
            gap: gap)
    }

    /// The same hit test over **drawn** widths — the shape a strip with collapsed lanes in it has to
    /// ask, since a slim strip's width is a fixed figure rather than a multiple of the standard
    /// (`drawnWidths(of:standard:gap:collapsedWidth:)`).
    ///
    /// This is the primitive and the unit-count version above is its wrapper: one walk, one origin
    /// convention, so a board with no folded lane cannot answer differently from one with.
    static func laneIndex(atX x: CGFloat, widths: [CGFloat], gap: CGFloat) -> Int? {
        var left = gap
        for (index, width) in widths.enumerated() {
            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`, whose ceiling is the strip's capacity (`resizeMaxUnits`)
    /// — there is no width cap to respect (03-board-ui.md § Lane: "1×, 2×, 3×, … — no cap"), and
    /// past the screen fit the tick keeps firing, re-dividing rather than growing the window.
    ///
    /// **`slotFor` rather than a standard**, because the standard is no longer one number for the
    /// whole gesture: beyond the screen fit each further unit re-divides the pinned strip, so slot
    /// `k` and slot `k + 1` are measured against *different* standards (`resizeStandard`). The
    /// thresholds are unchanged in form — they simply ask the caller how wide each slot would be.
    /// The band stays non-empty in the re-divide too: the slots still grow strictly with `k`, since
    /// a unit added to a lane takes more from the strip than the re-divide gives back.
    static func snappedUnits(
        liveWidth: CGFloat,
        currentUnits: Int,
        slotFor: (Int) -> CGFloat,
        gap: CGFloat,
        allowedRange: ClosedRange<Int>,
        reentry: CGFloat
    ) -> Int {
        if currentUnits < allowedRange.upperBound, liveWidth > slotFor(currentUnits) + gap {
            return currentUnits + 1
        }
        if currentUnits > allowedRange.lowerBound {
            if liveWidth < slotFor(currentUnits - 1) + gap - reentry {
                return currentUnits - 1
            }
        }
        return currentUnits
    }

    /// The same snap where every slot is measured against one standard — the whole of the gesture
    /// below the screen fit, and the shape the hit-testing and layout call sites think in.
    static func snappedUnits(
        liveWidth: CGFloat,
        currentUnits: Int,
        standard: CGFloat,
        gap: CGFloat,
        allowedRange: ClosedRange<Int>,
        reentry: CGFloat
    ) -> Int {
        snappedUnits(
            liveWidth: liveWidth,
            currentUnits: currentUnits,
            slotFor: { slotWidth(units: $0, standard: standard, gap: gap) },
            gap: gap,
            allowedRange: allowedRange,
            reentry: reentry)
    }

    /// 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: the rubber band "moves to the true end
    /// of travel — the strip's own capacity"). 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 this is **not the tick's ceiling** — it is the boundary where
    /// one mechanism hands over to the other (settled 2026-08-08, 03-board-ui.md § Lane). Up to it
    /// the drag grows the window and the siblings keep their pixels; past it the window is spent and
    /// each further tick re-divides instead (`resizeStandard`), which is how a lane keeps growing at
    /// the siblings' expense on a full screen. A window with no headroom at all answers
    /// `currentUnits`, so the very first tick is already a re-divide.
    ///
    /// 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)
    }

    // MARK: - The drag past the screen: the re-divide

    /// The strip's width once the window has grown as far as its screen allows — the width every
    /// tick past the screen fit re-divides, since the window is pinned from there on.
    ///
    /// The drag-start width is **derived, not measured**: the strip always fills exactly
    /// (`standardWidth`), so a frozen standard `s` over `T` units means a strip of `s·T + g·(T + 1)`
    /// and nothing else. Growth adds one `s + g` step per unit of on-screen headroom. Deriving it is
    /// what makes the boundary free: a measured viewport width, carrying whatever half-point the
    /// layout rounded to, would put a visible step where the two regimes meet.
    static func pinnedStripWidth(
        startUnits: Int,
        startStandard: CGFloat,
        startTotalUnits: Int,
        fittingUnits: Int,
        gap: CGFloat
    ) -> CGFloat {
        let total = CGFloat(max(1, startTotalUnits))
        let atStart = startStandard * total + gap * (total + 1)
        return atStart + CGFloat(max(0, fittingUnits - startUnits)) * (startStandard + gap)
    }

    /// The strip's standard (1×) width part-way through a right-edge drag, for a dragged lane
    /// spanning `units` — the whole of the drag's two regimes in one function (03-board-ui.md
    /// § Lane, settled 2026-08-08).
    ///
    /// At or below the screen fit the answer is the standard frozen at drag start: the window takes
    /// the step, so the division is unchanged and every other lane keeps its exact pixels. Past the
    /// fit the window is pinned, so each further unit the dragged lane claims is one more unit the
    /// same `pinnedStripWidth` has to divide across — the siblings compress, which is the stepper's
    /// mechanism arriving under the drag's fingers.
    ///
    /// **The regimes meet with no pixel jump.** At `units == fittingUnits` the pinned width divided
    /// across its own unit total is `startStandard` exactly, by the exact-fill identity
    /// `pinnedStripWidth` is built from — the same reason the release hands back to the resting
    /// layout without a flinch (`LaneResizeSession`).
    static func resizeStandard(
        forUnits units: Int,
        startUnits: Int,
        startStandard: CGFloat,
        startTotalUnits: Int,
        fittingUnits: Int,
        gap: CGFloat
    ) -> CGFloat {
        guard units > fittingUnits else { return startStandard }
        return standardWidth(
            stripWidth: pinnedStripWidth(
                startUnits: startUnits, startStandard: startStandard,
                startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap),
            totalUnits: max(1, startTotalUnits) + (units - startUnits),
            gap: gap)
    }

    /// The drag's ceiling — the true end of travel, which past the screen fit is a question about
    /// the strip's capacity rather than about the screen.
    ///
    /// The re-divide can always take one more unit, but not usefully forever: at some total
    /// `standardWidth`'s 1pt floor engages and the strip stops filling exactly, which is the point
    /// where the arithmetic stops describing anything on screen. That total is the largest `T`
    /// satisfying `(W − g·(T + 1)) / T ≥ 1`, i.e. `floor((W − g) / (1 + g))`, and the dragged lane's
    /// ceiling is that total read back through the units it contributed. This is where the rubber
    /// band now sits (§ Lane: "the true end of travel — the strip's own capacity").
    ///
    /// **Never below the screen fit**, and so never below the count the drag started from —
    /// shrinking is always allowed. A degenerate strip (a non-finite standard, a gap at or below
    /// −1pt, a capacity under one whole unit) falls back to the fit rather than inventing a bound.
    static func resizeMaxUnits(
        startUnits: Int,
        startStandard: CGFloat,
        startTotalUnits: Int,
        fittingUnits: Int,
        gap: CGFloat
    ) -> Int {
        let fit = max(startUnits, fittingUnits)
        guard startStandard.isFinite, gap.isFinite, gap > -1 else { return fit }
        let width = pinnedStripWidth(
            startUnits: startUnits, startStandard: startStandard,
            startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
        guard width.isFinite else { return fit }
        let capacity = (width - gap) / (1 + gap)
        guard capacity >= 1 else { return fit }
        let total = Int(min(capacity, CGFloat(Int.max) / 2).rounded(.down))
        return max(fit, startUnits + (total - max(1, startTotalUnits)))
    }

    /// How far the window moves on a tick from `oldUnits` to `newUnits`: one `step` per unit of that
    /// change lying **inside** the screen fit, and nothing at all for the part beyond it, where the
    /// re-divide has taken over and the window is pinned (03-board-ui.md § Lane).
    ///
    /// A difference of clamped counts rather than a per-step walk, because a flick can cross the
    /// boundary in one gesture event and the two sides must net out exactly: `F − 1 → F + 2` is one
    /// step (only the first unit was ever the window's to give), `F + 2 → F + 5` is none, and
    /// `F + 2 → F − 1` hands that one step back. Shrinking mirrors growing by construction.
    static func resizeWindowDelta(
        from oldUnits: Int,
        to newUnits: Int,
        fittingUnits: Int,
        step: CGFloat
    ) -> CGFloat {
        CGFloat(min(newUnits, fittingUnits) - min(oldUnits, fittingUnits)) * step
    }
}
