import AppKit
import SwiftUI

/// The invisible 12pt grab strip overlaid at a lane's trailing edge that drives a
/// `LaneResizeSession` from a `DragGesture` (03-board-ui.md § Lane, right-edge drag-to-resize).
///
/// Overlaid so ~8pt hangs into the inter-lane gap and only ~4pt sits over the lane itself (clear of
/// the lane's own vertical scrollbar, which lives at that inner edge). **Every lane gets one, the
/// last included** — the rightmost lane's drag is the one that grows the window into free screen
/// space, which is the interaction's headline case.
///
/// It carries no drop target of its own, so it never participates in card/lane/file drops, and as an
/// overlay it hit-tests above the lane body's own gestures.
struct LaneResizeHandle: View {

    let store: BoardStore
    let session: LaneResizeSession

    let laneID: ItemID

    /// The lane's committed unit count — the k the session starts from. What is **on screen**, which
    /// while a release hold stands is the width that release wrote rather than the stale snapshot's
    /// (`LaneResizeSession.displayUnits(of:)`).
    let committedUnits: Int

    /// The strip's standard (1×) width THIS render; captured as the frozen standard the instant the
    /// drag begins.
    let standard: CGFloat
    let gap: CGFloat

    /// The strip's whole unit total THIS render — every lane's units plus the shown trash's fixed
    /// one, the same divide the resting layout runs on (`BoardView.stripTotalUnits`). The session
    /// needs it because past the screen's fit the drag re-divides the strip rather than growing the
    /// window (03-board-ui.md § Lane, settled 2026-08-08), and a re-divide is a fact about the whole
    /// strip rather than about this lane.
    let totalUnits: Int

    /// How the session reaches the host window it resizes. A closure rather than a stored
    /// `NSWindow?` because the window attaches asynchronously (`WindowAccessor`), and a value
    /// captured in an early body evaluation would be `nil` for the window's whole life.
    let window: @MainActor () -> NSWindow?

    /// Guards `NSCursor` push/pop balance — a fast cursor can leave the strip mid-drag, and the drag
    /// can end on or off it, so pushes and pops must be idempotent to never leave a stuck resize
    /// cursor.
    @State private var cursorPushed = false

    /// The board's ruler (`BoardZoom`) — the gap this strip is proportioned against moves with the
    /// level, so the grab target has to move with it too or it drifts off the gap it lives in.
    @Environment(\.boardZoom) private var zoom

    /// The grab strip's width and its rightward shift — both font-derived, because the inter-lane
    /// gap they are proportioned against is (`BoardMetrics.stripGap`, 10-accessibility.md's
    /// full-relative-scaling rule). At the standard body size they are the 12pt and 8pt the strip
    /// has always used: with the strip trailing-aligned, +8 leaves 4pt over the lane and hangs 8pt
    /// into the gap (clear of the lane's own scrollbar).
    private var handleWidth: CGFloat { BoardMetrics.resizeHandleWidth(bodyPointSize: zoom.bodyPointSize) }

    private var overhang: CGFloat { BoardMetrics.resizeHandleOverhang(bodyPointSize: zoom.bodyPointSize) }

    var body: some View {
        Color.clear
            .frame(width: handleWidth)
            .frame(maxHeight: .infinity)
            .contentShape(Rectangle())
            .offset(x: overhang)
            .onHover { inside in
                if inside { pushCursor() } else { popCursor() }
            }
            // `.global` (window-fixed) space, NOT the handle's own: a tick moves the handle with its
            // lane, but the window's top-left is pinned (right-edge growth), so a window-fixed
            // translation stays a faithful physical-cursor delta throughout the drag.
            .gesture(
                DragGesture(minimumDistance: 2, coordinateSpace: .global)
                    .onChanged { value in
                        // `isDragging`, not `governs`: a release hold on this very lane is still
                        // governing the strip, and a new drag begins over it rather than being
                        // mistaken for the old one still running (`LaneResizeSession.begin`).
                        if !session.isDragging(laneID) {
                            // m5-drag: a resize and a card/lane move must not run at once — they
                            // would both mutate the same strip layout. The board's drag state does
                            // not exist yet; when it does, this is where the `isDragging` guard goes.
                            pushCursor()
                            session.begin(laneID: laneID, units: committedUnits,
                                          standard: standard, gap: gap,
                                          totalUnits: totalUnits, window: window())
                        }
                        session.update(translation: value.translation.width)
                    }
                    .onEnded { _ in
                        guard session.isDragging(laneID) else { return }
                        session.end { id, units in store.setLaneWidth(id, units: units) }
                        popCursor()
                    }
            )
            // **Pointer-only, and out of the tree** — "the header context menu's width stepper — and
            // its keyboard face, the Increase/Decrease Lane Width menu items — is the accessible
            // path; edge drag is enhancement only" (10-accessibility.md ▸ Moving without dragging).
            // An invisible strip that can only be dragged is a stop with nothing behind it, and it
            // would sit between two lane containers in the strip's traversal.
            .accessibilityHidden(true)
    }

    private func pushCursor() {
        guard !cursorPushed else { return }
        NSCursor.resizeLeftRight.push()
        cursorPushed = true
    }

    private func popCursor() {
        guard cursorPushed else { return }
        NSCursor.pop()
        cursorPushed = false
    }
}
