import AppKit
import QuartzCore
import SwiftUI

/// Window-local state for an in-flight lane resize — the right-edge drag of 03-board-ui.md § Lane.
/// At most one runs per board window at a time; `BoardView` owns it as `@State` and hands it to the
/// lanes and their grab strips.
///
/// The interaction has three collaborating pieces: `LaneLayoutMath` (the pure geometry), this
/// session (the live state plus the per-tick window resize), and `LaneResizeHandle` (the invisible
/// grab strip that drives it from a `DragGesture`). All three are ported from the pathfinder's
/// proven `ColumnResize.swift`, which is what 03-board-ui.md's "pathfinder behavior, proven" refers
/// to.
///
/// ### The invariant that makes it feel solid
///
/// **While a session is active, every OTHER lane keeps its exact pixel width.** That is achieved by
/// freezing the strip's standard (1×) width at drag start and sizing the *window* so that after
/// each snap tick the ordinary viewport-derived formula reproduces that frozen standard exactly —
/// so releasing the drag hands back to the resting layout with no pixel jump. This is the opposite
/// mechanism from the stepper (and its ⌥⌘→/⌥⌘← keyboard face), which never touches the window and
/// re-divides the existing width across the new unit total; the design is explicit that
/// window-growing behaviour belongs to the drag alone.
///
/// The session owns the two things that must move together on each tick: the SwiftUI unit count
/// (`units`, which drives the shadow slot, the siblings' positions, and the resizing lane's masonry
/// column count) and the host window's width. They animate on matching curves — `Motion.laneResize`
/// and `Motion.laneResizeWindowDuration`, the two faces of 03-board-ui.md § Motion's lane-resize
/// entry — so the window edge and the lanes to its right travel as one.
@MainActor
@Observable
final class LaneResizeSession {

    /// The lane being resized; `nil` when idle. Observed — flipping it drives `BoardView`'s
    /// frozen-standard override and the shadow slot on and off, and `LaneView`'s column count.
    private(set) var laneID: ItemID?

    /// The dragged lane's live rendered width — tracks the cursor continuously (rubber-banded at
    /// the ends), so its masonry reflows live between ticks.
    private(set) var liveWidth: CGFloat = 0

    /// The snapped unit count k. Drives the shadow slot width, the layout slot the siblings
    /// position off, and the resizing lane's masonry column count. Ticks by ±1 and animates.
    private(set) var units: Int = 1

    /// The strip's standard (1×) width, frozen at drag start. Used for ALL lane widths in
    /// `BoardView` while a session is active — the window is animating mid-session, so recomputing
    /// the standard from the live viewport width would feed the animation back into the layout and
    /// pulse every lane. Read within renders already triggered by the observed properties above, so
    /// it need not itself be observed.
    @ObservationIgnored private(set) var standard: CGFloat = 1

    /// The strip's inter-lane gap (== `BoardView.spacing`), captured at begin.
    @ObservationIgnored private var gap: CGFloat = 12

    /// The committed unit count at drag start — the anchor the drag translation is measured from.
    @ObservationIgnored private var startUnits: Int = 1

    /// The largest unit count that fits on screen. The drag's only ceiling: Lanework's `width` has
    /// no cap (03-board-ui.md § Lane), so nothing else bounds growth.
    @ObservationIgnored private var fittingUnits: Int = 1

    /// The host window, resized by ±(standard + gap) on each tick. Weak — a window can close,
    /// though a resize cannot outlive the gesture that drives it.
    @ObservationIgnored private weak var window: NSWindow?

    /// Reduce Motion, read once at `begin` and frozen for the gesture (10-accessibility.md's
    /// lane-resize commitment — "the lane-resize rubber-band feedback … gets reduced variants").
    ///
    /// Frozen rather than re-read per tick because the two halves of a tick — the unit count and the
    /// window's width — must never disagree about which variant they are in: a setting flipped
    /// between them would animate the lanes inside a window that jumped, which is the one thing this
    /// session's whole matched-curve design exists to prevent. Read from AppKit rather than from the
    /// SwiftUI environment because this type animates an `NSWindow` and has no environment to read
    /// (`Motion.prefersReducedMotion`).
    @ObservationIgnored private var reducedMotion = false

    var isActive: Bool { laneID != nil }

    func isResizing(_ id: ItemID) -> Bool { laneID == id }

    /// The tick-down re-entry distance: how far the live edge must retreat back into a gap it has
    /// already crossed before the shadow shrinks (03-board-ui.md § Lane's "10pt release
    /// hysteresis" — see `LaneLayoutMath.snappedUnits`). Fixed in points, not proportional to
    /// `standard`: it only needs to be comfortably larger than cursor jitter, which 10pt is
    /// regardless of lane size.
    private let reentry: CGFloat = 10

    /// The rubber-band overshoot fraction past the end slots.
    private let resistance: CGFloat = 0.25

    /// Unit counts the tick may reach: one up to the on-screen fit. The floor is 1 because a lane
    /// spans at least one unit; there is no ceiling but the screen.
    private var allowedRange: ClosedRange<Int> {
        1...max(1, fittingUnits)
    }

    private var minSlot: CGFloat {
        LaneLayoutMath.slotWidth(units: allowedRange.lowerBound, standard: standard, gap: gap)
    }

    private var maxSlot: CGFloat {
        LaneLayoutMath.slotWidth(units: allowedRange.upperBound, standard: standard, gap: gap)
    }

    // MARK: - Lifecycle

    /// Starts a resize of `laneID`, freezing the standard width and the gap and measuring how far
    /// the window can grow on its current screen.
    func begin(laneID: ItemID, units: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) {
        self.laneID = laneID
        self.startUnits = units
        self.units = units
        self.standard = standard
        self.gap = gap
        self.window = window
        self.reducedMotion = Motion.prefersReducedMotion
        self.liveWidth = LaneLayoutMath.slotWidth(units: units, standard: standard, gap: gap)
        self.fittingUnits = Self.fittingMaxUnits(currentUnits: units, standard: standard, gap: gap, window: window)
    }

    /// Applies a drag translation (points, measured from the gesture's start): tracks the live width
    /// to the cursor with end-resistance, then ticks the snapped unit count to its fixed point and
    /// applies the result in one animated step.
    ///
    /// A single call to `snappedUnits` only ever steps by ±1, but a fast flick can carry the live
    /// width across two (or more) thresholds between consecutive gesture events, so it is iterated
    /// here until it stops moving — bounded by `allowedRange`'s width, so this never loops more than
    /// a couple of times in practice. `tick(to:)` already computes a correct multi-step window-size
    /// delta from `units` to the target, so only the FINAL target gets one `tick` call, not one per
    /// intermediate step.
    func update(translation: CGFloat) {
        guard isActive else { return }
        let startSlot = LaneLayoutMath.slotWidth(units: startUnits, standard: standard, gap: gap)
        liveWidth = LaneLayoutMath.resistedWidth(
            proposed: startSlot + translation,
            minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
        var target = units
        while true {
            let next = LaneLayoutMath.snappedUnits(
                liveWidth: liveWidth, currentUnits: target,
                standard: standard, gap: gap, allowedRange: allowedRange, reentry: reentry)
            if next == target { break }
            target = next
        }
        if target != units { tick(to: target) }
    }

    /// Commits the snapped unit count and dismisses the session. Order matters for a flash-free
    /// handoff: write the model FIRST (the session is still active, so the frozen standard still
    /// governs and the shadow slot does not budge), THEN clear the session inside the snap animation
    /// — at which point `BoardView` reverts to the viewport-derived standard, which the window
    /// sizing has kept equal to the frozen one, so the resting layout reproduces the same pixels
    /// while the live width animates the last sub-tick of overflow/underfill away. The window and
    /// the siblings are already in place; neither is touched here.
    ///
    /// The commit is a *write*, not a snapshot mutation: it goes to disk through the Writer and
    /// comes back as an ordinary reload (02-architecture.md § Layering's one-way flow), so the lane
    /// briefly renders at its pre-drag width if the write fails — which is exactly the honesty the
    /// banner then explains.
    func end(commit: (ItemID, Int) -> Void) {
        guard let laneID else { return }
        commit(laneID, units)
        withAnimation(Motion.laneResize(reduced: reducedMotion)) {
            self.laneID = nil
            self.liveWidth = 0
        }
    }

    // MARK: - Tick

    /// A single snapped step: animate the unit count (which resizes the shadow slot, translates the
    /// lanes to the right, and reflows the resizing lane's interior columns) and the window's width
    /// on the two matching lane-resize curves. The window grows and shrinks at its RIGHT edge —
    /// width changes by ±step with `origin.x` and height held — so everything to the left, including
    /// this lane's own left edge and the drag's coordinate origin, stays put.
    private func tick(to newUnits: Int) {
        let delta = CGFloat(newUnits - units) * (standard + gap)
        withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits }
        guard let window else { return }
        var frame = window.frame
        frame.size.width += delta   // right-edge growth: origin and height unchanged
        // Reduce Motion's variant of the rubber-band feedback is the *instant* one
        // (10-accessibility.md): the window takes its new width outright, matching the unit count
        // that just did the same. Spelled as the absence of an animation group rather than as a
        // zero-duration one — see `Motion.laneResizeWindowDuration` for why a zero is not trusted.
        guard !reducedMotion else {
            window.setFrame(frame, display: true)
            return
        }
        NSAnimationContext.runAnimationGroup { context in
            context.duration = Motion.laneResizeWindowDuration
            context.timingFunction = Motion.laneResizeWindowTiming
            context.allowsImplicitAnimation = true
            window.setFrame(frame, display: true)
        }
    }

    /// The on-screen fit, from the window's headroom to its screen's visible frame — the hard stop
    /// 03-board-ui.md § Lane requires ("Growth hard-stops at the screen's visible frame"). Defers
    /// the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current count is
    /// the honest answer (growth needs a window to grow).
    private static func fittingMaxUnits(currentUnits: Int, standard: CGFloat, gap: CGFloat, window: NSWindow?) -> Int {
        guard let window, let screen = window.screen ?? NSScreen.main else { return currentUnits }
        let headroom = screen.visibleFrame.maxX - window.frame.maxX
        return LaneLayoutMath.maxUnits(currentUnits: currentUnits, headroom: headroom, step: standard + gap)
    }
}
