import AppKit
import QuartzCore
import SwiftUI

// MARK: - The release hold

/// The hand-off condition for **the lane-resize release hold**, as a value so the state machine is
/// testable without a filesystem.
///
/// The release *writes* the lane's new width, and that write rides the one-way flow — Writer → disk
/// → watcher → reload (02-architecture.md § Layering). Between the release and that echo the
/// snapshot still says the OLD unit count while the window has already grown, so a session that
/// cleared at release would hand the strip to `LaneLayoutMath.standardWidth` dividing the *new*
/// window width by the *stale* unit total: every lane takes a proportionally wrong width for a beat
/// — the resized one snapping back toward its old share — and the board then jumps a second time
/// when the echo lands. This is the drag's committed-overlay hold (DRAG-REORDER.md § The
/// committed-overlay hold) applied to the resize, which lives outside the drag machinery and so
/// needs its own: the frozen standard and the written unit count keep governing until the snapshot
/// carries the write.
///
/// A deliberately separate type from `CommittedHold` rather than a reuse of it. The two share only
/// their shape: that hold's subject is a *rearrangement*, retired by any snapshot landing on the
/// board, and this one's is a *value*, which can only be retired by a snapshot that actually carries
/// it — an unrelated reload landing first must NOT release this hold, or the two-step is back.
struct LaneWidthHold: Equatable, Sendable {

    /// The lane the release wrote.
    var laneID: ItemID

    /// The unit count it wrote — what the landed snapshot has to agree with.
    var units: Int

    /// How long the hold may stand with no echo arriving. `CommittedHold.timeout`'s figure and its
    /// reasoning: comfortably longer than a write plus a watcher round trip, short enough that a
    /// write nobody echoes does not leave the board drawing a width it never got.
    static let timeout: Duration = .milliseconds(1500)

    /// Whether a snapshot holding `lanes` retires this hold.
    ///
    /// The width is read exactly as the board reads it (`LaneLayoutMath.displayUnits`), which is the
    /// whole of the one-unit case: **a width landing on 1 removes the `width` key**
    /// (`BoardStore.writeLaneWidths`, the remove-at-default family), so the echo carrying that write
    /// has no key to compare against — only the display reading sees the 1 that was asked for.
    ///
    /// A lane no longer in the snapshot retires it too: it was deleted or moved away under the
    /// gesture, no echo naming it is ever coming, and the snapshot is the authority.
    func isRetired(by lanes: [Lane]) -> Bool {
        guard let lane = lanes.first(where: { $0.id == laneID }) else { return true }
        return LaneLayoutMath.displayUnits(of: lane) == units
    }
}

// MARK: - The session

/// 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 the window still has screen to grow into, 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.
///
/// **At the screen's visible frame that stops and the drag degrades to the re-divide** (settled
/// 2026-08-08, 03-board-ui.md § Lane). Past the fit the window is pinned and each further tick
/// divides the same width across one more unit — the stepper's mechanism, arriving under the drag's
/// fingers — so the siblings compress and the lane keeps growing at their expense. A window with no
/// headroom to begin with (already at the edge, or full screen) re-divides from the very first
/// snap, which is what makes the drag work at all on a maximised window. The boundary costs nothing:
/// the re-divided standard at the fit *is* the frozen standard (`LaneLayoutMath.resizeStandard`), so
/// `standard` below is a function of the live unit count rather than one number for the gesture.
///
/// 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. Past the fit only the first
/// of the two moves, and the window is left exactly where the screen ended.
///
/// ### Three phases, not two
///
/// *Idle* (`laneID == nil`), *dragging* (`isDragging`), and — after the release — *holding*, which
/// is the same governance standing over a write that has not echoed back yet (`LaneWidthHold`). The
/// layout reads one question throughout, `governs(_:)`, so the strip cannot tell the last two
/// apart; only the handle's gesture does, since a hold is not something a mouse is still driving.
@MainActor
@Observable
final class LaneResizeSession {

    /// The lane this session governs — being dragged, or holding its written width until the echo;
    /// `nil` when idle. Observed — flipping it drives `BoardView`'s session-standard override and the
    /// shadow slot on and off, and `LaneView`'s column count.
    private(set) var laneID: ItemID?

    /// The release hold, or `nil` while the drag is still in flight (or the session idle).
    /// See `LaneWidthHold`.
    private(set) var hold: LaneWidthHold?

    /// 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.
    ///
    /// It stops ticking at the release and becomes **the written count** — the number the hold is
    /// waiting for the snapshot to agree with, and the one the strip lays the lane out at until it
    /// does.
    private(set) var units: Int = 1

    /// The strip's standard (1×) width **as of the live unit count**. 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.
    ///
    /// Within the screen fit this is the value frozen at drag start and nothing moves but the
    /// window; past it the pinned strip re-divides, and this is how every *other* lane learns to
    /// compress — `BoardView.standardWidth` hands the same number to all of them
    /// (`LaneLayoutMath.resizeStandard`).
    var standard: CGFloat { standard(forUnits: units) }

    /// The strip's standard (1×) width frozen at drag start — regime A's answer whole, and regime
    /// B's starting point.
    @ObservationIgnored private var startStandard: 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 strip's whole unit total at drag start — every lane's display units plus the trash
    /// column's fixed one while it is shown, exactly the total `BoardView` divides the resting
    /// layout by. The re-divide needs it twice over: it is what the pinned strip width is derived
    /// from, and what each extra unit the drag claims is added to.
    @ObservationIgnored private var startTotalUnits: Int = 1

    /// The largest unit count that still fits on screen — **the boundary between the two regimes**,
    /// not a ceiling. Up to it a tick grows the window; past it a tick re-divides
    /// (`LaneLayoutMath.maxUnits`, `fittingMaxUnits`).
    @ObservationIgnored private var fittingUnits: Int = 1

    /// The tick's actual ceiling: the strip's own capacity, the count past which the re-divide would
    /// break the exact fill (`LaneLayoutMath.resizeMaxUnits`). Computed once at begin, since every
    /// input to it is frozen there.
    @ObservationIgnored private var ceilingUnits: Int = 1

    /// The host window, resized by ±(standard + gap) on each tick that still has screen to move
    /// into, and left alone on the re-divide's. 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

    @ObservationIgnored private var holdTimeoutTask: Task<Void, Never>?

    /// How long this session's hold may stand with no echo arriving — `LaneWidthHold.timeout`, and a
    /// `var` for one reason only: the dissolve is a `Task` sleeping on the main actor, and a test
    /// that had to wait the real figure out would be 1.5 s of wall clock in the suite
    /// (`LaneResizeHoldTests`). Nothing in the app writes it.
    @ObservationIgnored var holdTimeout: Duration = LaneWidthHold.timeout

    /// Whether the session governs the strip's layout at all — its `standard` is in force for a drag
    /// and for the hold that follows it alike.
    var isActive: Bool { laneID != nil }

    /// Whether a gesture is still driving it. False during the hold, which no mouse is holding.
    var isDragging: Bool { laneID != nil && hold == nil }

    /// Whether the release has **settled**: the width is written and the layout it produced is being
    /// held until the echo reload lands (`LaneWidthHold`).
    var isSettled: Bool { hold != nil }

    /// Whether this session — dragging or holding — is the authority on `id`'s width.
    func governs(_ id: ItemID) -> Bool { laneID == id }

    /// Whether `id` is the lane a gesture is currently dragging.
    func isDragging(_ id: ItemID) -> Bool { laneID == id && hold == nil }

    /// The unit count `lane` spans **on screen right now**: this session's while it governs that
    /// lane — the live snapped count mid-drag, the written count mid-hold — and the snapshot's
    /// otherwise.
    ///
    /// The mid-hold answer is the point: for that stretch the snapshot's `width` is the *old* one,
    /// and every reader that took it at face value would draw the pre-drag layout.
    func displayUnits(of lane: Lane) -> Int {
        governs(lane.id) ? units : LaneLayoutMath.displayUnits(of: lane)
    }

    /// 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 strip's capacity. The floor is 1 because a lane
    /// spans at least one unit; the ceiling is where the re-divide runs out of strip to divide, the
    /// screen having stopped bounding it (03-board-ui.md § Lane, settled 2026-08-08).
    private var allowedRange: ClosedRange<Int> {
        1...max(1, ceilingUnits)
    }

    /// The standard the strip would be drawn at with the dragged lane spanning `units` — frozen
    /// within the screen fit, re-divided past it. Every slot the gesture measures goes through here.
    private func standard(forUnits units: Int) -> CGFloat {
        LaneLayoutMath.resizeStandard(
            forUnits: units, startUnits: startUnits, startStandard: startStandard,
            startTotalUnits: startTotalUnits, fittingUnits: fittingUnits, gap: gap)
    }

    /// The dragged lane's rendered width at `units` — its own units against the standard *that* count
    /// implies, which past the fit is not the standard the neighbouring counts imply.
    private func slot(forUnits units: Int) -> CGFloat {
        LaneLayoutMath.slotWidth(units: units, standard: standard(forUnits: units), gap: gap)
    }

    private var minSlot: CGFloat { slot(forUnits: allowedRange.lowerBound) }

    private var maxSlot: CGFloat { slot(forUnits: allowedRange.upperBound) }

    // 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.
    ///
    /// **A standing hold is superseded, not waited out** — a second drag is the user's newer answer
    /// about the same strip, and its own release will arm the hold that matters. `units` is the
    /// anchor the caller reads off the screen (`displayUnits(of:)`), so a drag begun mid-hold starts
    /// from the width that is showing rather than from the stale snapshot's.
    ///
    /// `totalUnits` is the strip's whole divide — every lane plus the shown trash's fixed one, the
    /// same total the resting layout uses. The re-divide past the screen fit is arithmetic *about
    /// the strip*, not about the dragged lane, so it cannot be reconstructed from the lane alone.
    func begin(
        laneID: ItemID,
        units: Int,
        standard: CGFloat,
        gap: CGFloat,
        totalUnits: Int,
        window: NSWindow?
    ) {
        endHold()
        self.laneID = laneID
        self.startUnits = units
        self.units = units
        self.startStandard = standard
        self.startTotalUnits = max(1, totalUnits)
        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)
        self.ceilingUnits = LaneLayoutMath.resizeMaxUnits(
            startUnits: units, startStandard: standard, startTotalUnits: self.startTotalUnits,
            fittingUnits: self.fittingUnits, gap: gap)
    }

    /// 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 isDragging else { return }
        liveWidth = LaneLayoutMath.resistedWidth(
            proposed: slot(forUnits: startUnits) + translation,
            minSlot: minSlot, maxSlot: maxSlot, resistance: resistance)
        var target = units
        while true {
            let next = LaneLayoutMath.snappedUnits(
                liveWidth: liveWidth, currentUnits: target,
                slotFor: slot(forUnits:), gap: gap, allowedRange: allowedRange, reentry: reentry)
            if next == target { break }
            target = next
        }
        if target != units { tick(to: target) }
    }

    /// Commits the snapped unit count and enters the **release hold**.
    ///
    /// 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). Clearing the
    /// session here — as this used to — hands the strip back to the viewport-derived standard one
    /// whole round trip too early: the window is already at its new width but the snapshot still
    /// carries the old unit total, so the division is wrong for every lane and the board visibly
    /// two-steps, once into that stale arrangement and again when the echo lands. So the session
    /// keeps governing (`LaneWidthHold`), and all that happens here is the last sub-tick of
    /// overflow/underfill animating away against the width the release actually wrote.
    ///
    /// `commit` answers **whether bytes reached disk** (`BoardStore.setLaneWidth`). A `false` — a
    /// refusal the banner is already explaining, or a drag that ended on the width it started from —
    /// means no echo is ever coming, so the hold is never armed and the snapshot takes the layout
    /// back immediately: the lane returns to its pre-drag width, which is the truth.
    func end(commit: (ItemID, Int) -> Bool) {
        guard let laneID, hold == nil else { return }
        let committed = units
        guard commit(laneID, committed) else { return dissolve() }

        let hold = LaneWidthHold(laneID: laneID, units: committed)
        self.hold = hold
        let timeout = holdTimeout
        holdTimeoutTask?.cancel()
        holdTimeoutTask = Task { @MainActor [weak self] in
            try? await Task.sleep(for: timeout)
            guard !Task.isCancelled, let self else { return }
            self.expire(hold)
        }

        // The settle: the live width, which has been tracking the cursor, comes to rest on the slot
        // the written count names — measured against the standard THAT count implies, which is the
        // one still governing the strip (frozen within the fit, re-divided past it).
        withAnimation(Motion.laneResize(reduced: reducedMotion)) {
            liveWidth = slot(forUnits: committed)
        }
    }

    /// **The hand-off**: a walk landed, so a hold whose width the snapshot now agrees with dissolves
    /// and the snapshot is the authority again.
    ///
    /// Called from `BoardView`'s watch on `BoardStore.landedReloads` — *every* landed walk, not just
    /// the ones that assigned. The condition is "the width the write named is what the current
    /// snapshot says", never "a counter moved": a reload whose tree came back value-equal skips the
    /// assignment and does not bump `snapshotGeneration` (02-architecture.md § Live-reload
    /// resilience), and a hold keyed to that bump would sit out its whole deadline over a snapshot
    /// that already agreed with it. Retiring on the *value* is right whichever counter moved.
    func handOff(against lanes: [Lane]) {
        guard let hold, hold.isRetired(by: lanes) else { return }
        dissolve()
    }

    /// The deadline's own body, spelled as a method rather than inlined in the `Task` so the
    /// dissolve can be pinned directly as well as through the clock (`LaneResizeHoldTests`).
    ///
    /// A hold that has already handed off — or been superseded by a second drag's — is not this
    /// one's to end.
    func expire(_ hold: LaneWidthHold) {
        guard self.hold == hold else { return }
        dissolve()
    }

    /// Governance ends: the strip goes back to the viewport-derived standard and the snapshot's unit
    /// counts. Animated on the resize curve because the two are only *meant* to be the same pixels —
    /// on the echo path they are, and the animation shows nothing; on the timeout path they are not,
    /// and what moves is the resize un-happening.
    private func dissolve() {
        endHold()
        withAnimation(Motion.laneResize(reduced: reducedMotion)) {
            laneID = nil
            liveWidth = 0
        }
    }

    private func endHold() {
        hold = nil
        holdTimeoutTask?.cancel()
        holdTimeoutTask = nil
    }

    // 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.
    ///
    /// **The window only takes the part of the step that fits on screen**
    /// (`LaneLayoutMath.resizeWindowDelta`): past the fit the delta is zero, the unit count moves
    /// alone, and what the eye sees is the siblings compressing into the width the window already
    /// has. A zero delta skips the animation group outright rather than animating a frame to itself —
    /// running the group for nothing would leave every re-divide tick paying for a window animation.
    private func tick(to newUnits: Int) {
        let delta = LaneLayoutMath.resizeWindowDelta(
            from: units, to: newUnits, fittingUnits: fittingUnits, step: startStandard + gap)
        withAnimation(Motion.laneResize(reduced: reducedMotion)) { units = newUnits }
        guard let window, delta != 0 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 — **where window
    /// growth ends and the re-divide begins** (03-board-ui.md § Lane: "at the screen's visible frame
    /// the window stops and the drag degrades to the re-divide"), which is a handover and not a stop.
    /// Defers the arithmetic to `LaneLayoutMath.maxUnits`; with no window to measure, the current
    /// count is the honest answer — growth needs a window to grow, so every tick re-divides, exactly
    /// as it does for a window already pinned to the screen's edge.
    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)
    }
}
