import AppKit
import NucleicCore
import SwiftUI

/// Wraps the chat (`SessionDetailView`) in a paneling system: the chat is always
/// centered, with an optional left column, right column, and a row below it. Panels
/// (file explorer, terminal) are dragged from the toolbar palette onto an edge, or
/// added via the Panels menu; the arrangement is the global working layout held by
/// `PanelLayoutStore` and can be saved as a named layout with a default.
///
/// Design: layouts are app-wide rather than per-session so a user settles on one
/// cockpit and every chat opens into it. Each panel is keyed on session + instance
/// id, so switching chats re-roots the file tree / shell at the new worktree while
/// keeping a panel's state stable within a chat.
///
/// A pane can be dragged to any *position*, not just any region: dropping partway down
/// a column inserts it there, and an insertion bar tracks the cursor so the landing
/// place is never a guess. Every rearrangement springs rather than snapping — see
/// `PanelMotion` for why the arrangement animates but a divider drag does not.
struct PaneledChatView: View {
    @Environment(AppStore.self) private var store
    @Environment(PanelLayoutStore.self) private var panels
    @Environment(FileSelectionStore.self) private var fileSelection
    @Environment(\.appPalette) private var palette
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    @State private var savingLayout = false
    @State private var layoutNameDraft = ""
    @State private var managingLayouts = false

    /// The in-flight drag, shared with every pane header and region below.
    @State private var drag = PanelDragState()
    /// Each region's frame in the paneling root's coordinate space. This is what a drag
    /// is hit-tested against: the drag is a plain gesture (see `PanelDragState`), so the
    /// regions do no hit-testing of their own — the root resolves the cursor to a slot
    /// and an index itself.
    @State private var regionFrames: [PanelSlot: CGRect] = [:]
    /// The whole paneling area, for the edge bands that let a pane be dropped into a
    /// region that is currently empty (and therefore only a hairline wide).
    @State private var rootSize: CGSize = .zero

    /// Thickness of an empty region's drop rail, and of a resize handle.
    private let railThickness: CGFloat = 12
    private let handleThickness: CGFloat = 7
    /// Thickness of the insertion bar drawn at the pending drop position.
    private let insertionBarThickness: CGFloat = 3

    private var session: Session? { store.openSession }
    private var layout: PanelLayout { panels.working }

    /// The worktree's folder name — used as the subtitle for the Files and Terminal
    /// headers (both are rooted there), echoing the chat header's status line so the
    /// pane headers are the same height.
    private var worktreeName: String {
        guard let path = session?.worktreePath else { return "No worktree" }
        return (path as NSString).lastPathComponent
    }

    /// Per-kind subtitle shown under the title in each pane's chat-matching header.
    private func subtitle(for kind: PanelKind) -> String {
        switch kind {
        case .fileExplorer, .terminal, .vmMonitor:
            worktreeName
        case .editor:
            fileSelection.selected.map { ($0.path as NSString).lastPathComponent } ?? "No file open"
        case .subagents:
            subagentsSubtitle
        case .nashViewer:
            nashViewerSubtitle
        }
    }

    /// The Nash Viewer header's status line: how many shell emissions this chat has produced (the
    /// lifetime total, not the retained ring — it keeps counting past the trim, and includes what
    /// previous runs left in the durable feed), plus how many bytes nash intercepted crossing
    /// pipes, redirects, and `$(…)`. That byte figure is
    /// the panel's whole claim — data nothing else in the app can see — so it belongs in the header
    /// rather than costing a row of the narrow panel to display.
    private var nashViewerSubtitle: String {
        guard let id = session?.id else { return "No chat open" }
        let count = store.nashEvents.total(for: id)
        let emissions = switch count {
        case 0: "No emissions"
        case 1: "1 emission"
        default: "\(count) emissions"
        }
        // Retained rows only: the lifetime totals count events, not their sizes, and re-deriving
        // bytes past the ring trim would need payloads that were deliberately dropped.
        let bytes = store.nashEvents.events(for: id)
            .filter(\.kind.carriesPayload)
            .reduce(0) { $0 + ($1.bytes ?? 0) }
        guard bytes > 0 else { return emissions }
        return "\(emissions) · \(NashEventLog.formatBytes(bytes)) intercepted"
    }

    /// The Subagents header's status line: a live count of the workers this chat has spawned.
    private var subagentsSubtitle: String {
        guard let id = session?.id else { return "No chat open" }
        let count = store.subagentSummaries(for: id).count
        switch count {
        case 0: return "No subagents"
        case 1: return "1 subagent"
        default: return "\(count) subagents"
        }
    }

    /// Pane-specific header controls: the editor surfaces its Save/Preview icons in
    /// the header; other panes have none.
    private func headerAccessory(for kind: PanelKind) -> AnyView {
        switch kind {
        case .editor: AnyView(EditorHeaderControls())
        case .fileExplorer, .terminal, .vmMonitor, .subagents, .nashViewer: AnyView(EmptyView())
        }
    }

    var body: some View {
        GeometryReader { geo in
            ZStack(alignment: .topLeading) {
                HStack(spacing: 0) {
                    leftRegion(container: geo.size)
                    centerStack(container: geo.size)
                    rightRegion(container: geo.size)
                }
                // Keyed on the arrangement alone, so adding, closing, reordering and
                // re-slotting a pane all spring — while a divider drag, which changes only
                // the region sizes, keeps tracking the cursor frame-for-frame.
                .animation(PanelMotion.layout(reduceMotion), value: layout.arrangement)
                dragPreview
            }
            // The one space a panel drag is measured in: the gesture on a pane header, the
            // region frames it is tested against, and the floating preview all use it.
            .coordinateSpace(.named(PanelDragState.rootSpace))
            .onGeometryChange(for: CGSize.self) { $0.size } action: { rootSize = $0 }
        }
        .environment(drag)
        .toolbar { ToolbarItem { panelsMenu } }
        // A file path is worktree-relative to the open chat; clear the shared
        // selection when the chat changes so the Editor never shows another
        // worktree's file.
        .onChange(of: session?.id) { _, _ in fileSelection.clear() }
        .alert("Save Layout", isPresented: $savingLayout) {
            TextField("Layout name", text: $layoutNameDraft)
            Button("Cancel", role: .cancel) {}
            Button("Save") { panels.saveWorking(as: layoutNameDraft) }
        } message: {
            Text("Saves the current panel arrangement. Reusing a name overwrites that layout.")
        }
        .sheet(isPresented: $managingLayouts) { ManageLayoutsSheet() }
    }

    // MARK: - Regions

    @ViewBuilder
    private func leftRegion(container: CGSize) -> some View {
        if layout.left.isEmpty {
            edgeRail(.left)
                .transition(.opacity)
        } else {
            panelStack(.left, axis: .vertical)
                .frame(width: clampWidth(layout.leftWidth, container: container))
                .transition(PanelMotion.region(.left, reduceMotion))
            ResizeHandle(axis: .horizontal, sign: 1, value: layout.leftWidth,
                         range: widthRange(container),
                         onChange: { panels.setWidth($0, for: .left) },
                         onCommit: { panels.commitSizes() })
                .frame(width: handleThickness)
                .transition(.opacity)
        }
    }

    @ViewBuilder
    private func rightRegion(container: CGSize) -> some View {
        if layout.right.isEmpty {
            edgeRail(.right)
                .transition(.opacity)
        } else {
            ResizeHandle(axis: .horizontal, sign: -1, value: layout.rightWidth,
                         range: widthRange(container),
                         onChange: { panels.setWidth($0, for: .right) },
                         onCommit: { panels.commitSizes() })
                .frame(width: handleThickness)
                .transition(.opacity)
            panelStack(.right, axis: .vertical)
                .frame(width: clampWidth(layout.rightWidth, container: container))
                .transition(PanelMotion.region(.right, reduceMotion))
        }
    }

    @ViewBuilder
    private func centerStack(container: CGSize) -> some View {
        VStack(spacing: 0) {
            SessionDetailView()
                .frame(maxWidth: .infinity, maxHeight: .infinity)
            if layout.bottom.isEmpty {
                edgeRail(.bottom)
                    .transition(.opacity)
            } else {
                ResizeHandle(axis: .vertical, sign: -1, value: layout.bottomHeight,
                             range: heightRange(container),
                             onChange: { panels.setBottomHeight($0) },
                             onCommit: { panels.commitSizes() })
                    .frame(height: handleThickness)
                    .transition(.opacity)
                panelStack(.bottom, axis: .horizontal)
                    .frame(height: clampHeight(layout.bottomHeight, container: container))
                    .transition(PanelMotion.region(.bottom, reduceMotion))
            }
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity)
    }

    /// The panels in a slot, stacked flush along `axis` and separated by draggable dividers
    /// (no insets/cards). Each pane takes the share of the region its weight earns it, so a
    /// column split by hand keeps that split — across chats, and across launches, since the
    /// weights ride along in the persisted working layout.
    ///
    /// The whole region is also a *positional* drop target: where along the stack the pointer
    /// sits decides which index a dropped pane lands at, and the insertion bar shows that
    /// choice while the drag is still in the air.
    private func panelStack(_ slot: PanelSlot, axis: Axis) -> some View {
        let items = layout.instances(in: slot)
        return GeometryReader { geo in
            // The handles sit *between* the panes, so their thickness comes off the top before
            // the rest is shared out — otherwise the stack overflows its region by that much.
            let span = axis == .vertical ? geo.size.height : geo.size.width
            let gutters = handleThickness * CGFloat(max(items.count - 1, 0))
            let lengths = PanelLayout.paneLengths(
                shares: layout.shares(in: slot),
                available: max(0, span - gutters),
                minimum: minimumPaneLength(axis))
            Group {
                if axis == .vertical {
                    VStack(spacing: 0) { panelViews(items, axis: axis, lengths: lengths) }
                } else {
                    HStack(spacing: 0) { panelViews(items, axis: axis, lengths: lengths) }
                }
            }
            .frame(maxWidth: .infinity, maxHeight: .infinity)
        }
        .background(DetailSurface(layer: .inner))
        .measureRegion(slot, into: $regionFrames)
        .overlay { insertionBar(slot, axis: axis) }
        // Hand every pane the company it is keeping, so one can stand down a feature a
        // neighbour already covers — see ``EnvironmentValues/panelRegionKinds``.
        .environment(\.panelRegionKinds, Set(items.map(\.kind)))
    }

    @ViewBuilder
    private func panelViews(
        _ items: [PanelInstance], axis: Axis, lengths: [CGFloat]
    ) -> some View {
        ForEach(Array(items.enumerated()), id: \.element.id) { index, instance in
            if index > 0 {
                paneDivider(items: items, axis: axis, lengths: lengths, after: index - 1)
            }
            PanelContainer(
                instance: instance, session: session,
                headerStyle: .prominent,
                headerSubtitle: subtitle(for: instance.kind),
                headerAccessory: headerAccessory(for: instance.kind),
                resolveTarget: { [resolver] in resolver.target(at: $0) }
            ) {
                panels.remove(instance.id)
            }
            // Fixed along the stack's axis (that's the pane's share of the region), free
            // across it (that's the region's own width or height).
            .frame(width: axis == .horizontal ? lengths[safe: index] : nil,
                   height: axis == .vertical ? lengths[safe: index] : nil)
            .frame(maxWidth: axis == .vertical ? .infinity : nil,
                   maxHeight: axis == .horizontal ? .infinity : nil)
            .transition(PanelMotion.pane(reduceMotion))
        }
    }

    /// The divider between two stacked panes, and the handle that re-splits them.
    ///
    /// A drag here trades length between *these two* neighbours and leaves the rest of the
    /// column alone — the pair keeps its combined weight, so nothing below the divider shifts
    /// while you're dragging above it. Same control as the region dividers, so it grows the
    /// same accent hairline and puts up the same cursor.
    @ViewBuilder
    private func paneDivider(
        items: [PanelInstance], axis: Axis, lengths: [CGFloat], after index: Int
    ) -> some View {
        let minimum = minimumPaneLength(axis)
        let first = lengths[safe: index] ?? minimum
        let pair = first + (lengths[safe: index + 1] ?? minimum)
        ResizeHandle(
            axis: axis == .vertical ? .vertical : .horizontal,
            sign: 1,
            value: first,
            range: minimum ... max(minimum, pair - minimum),
            onChange: { resplit(items, at: index, firstLength: $0, pair: pair) },
            onCommit: { panels.commitSizes() }
        )
        .frame(width: axis == .horizontal ? handleThickness : nil,
               height: axis == .vertical ? handleThickness : nil)
    }

    /// Turn a divider's new position back into a pair of weights. Ratios, not lengths, are
    /// what get stored: the split then survives the window (or the column) being resized to
    /// any other size, which storing points would not.
    private func resplit(
        _ items: [PanelInstance], at index: Int, firstLength: CGFloat, pair: CGFloat
    ) {
        guard pair > 0, items.indices.contains(index), items.indices.contains(index + 1) else { return }
        let (a, b) = (items[index], items[index + 1])
        let combined = PanelInstance.sanitize(a.weight) + PanelInstance.sanitize(b.weight)
        let ratio = (firstLength / pair).clamped(to: 0.02 ... 0.98)
        panels.setPaneWeights([a.id: combined * ratio, b.id: combined * (1 - ratio)])
    }

    // MARK: - Drag targeting

    /// The pure resolver the drag is hit-tested against, built from the geometry measured
    /// this pass. See `PanelDropResolver` for the rules.
    private var resolver: PanelDropResolver {
        PanelDropResolver(
            regionFrames: regionFrames,
            rootSize: rootSize,
            shares: Dictionary(
                uniqueKeysWithValues: PanelSlot.allCases.map { ($0, layout.shares(in: $0)) }))
    }

    // MARK: - Drop feedback

    /// The pane under the cursor while it's being dragged.
    ///
    /// Deliberately a stand-in rather than the live pane: a second `TerminalPanel` would
    /// spawn a second shell and a second `VMMonitorPanel` a second video stream, so the
    /// preview reproduces the pane's *chrome* — its real title and status line, its real
    /// proportions — over an empty body. Sized down to fit a small box so it never covers
    /// the insertion bar it is being aimed at.
    @ViewBuilder
    private var dragPreview: some View {
        if let lift = drag.lift {
            let size = previewSize(for: lift.paneSize)
            PanelDragPreview(kind: lift.payload.kind, subtitle: lift.subtitle, size: size)
                // The cursor sits just inside the preview's upper-left, the way a system
                // drag image hangs off the pointer, rather than at its centre — which
                // would bury whatever is underneath.
                .position(x: lift.point.x + size.width * 0.18,
                          y: lift.point.y + size.height * 0.26)
                // A touch of lag behind the cursor is what gives the pane weight; any more
                // and it reads as dropped frames.
                .animation(.interactiveSpring(response: 0.11, dampingFraction: 0.86),
                           value: lift.point)
                .transition(.scale(scale: 0.88).combined(with: .opacity))
                .allowsHitTesting(false)
        }
    }

    /// Fit the pane's real proportions into a small box, with a floor so a very thin pane
    /// still has room for its two header lines.
    private func previewSize(for paneSize: CGSize) -> CGSize {
        let box = CGSize(width: 300, height: 220)
        let scale = min(box.width / max(paneSize.width, 1), box.height / max(paneSize.height, 1), 1)
        return CGSize(width: max(170, paneSize.width * scale),
                      height: max(112, paneSize.height * scale))
    }

    /// The bar marking where a dropped pane will land, plus a wash over the region that
    /// will receive it.
    ///
    /// Always mounted, and merely transparent when this region isn't the target, so
    /// sliding the cursor from one insertion point to the next *moves* the bar instead
    /// of blinking a second one into existence somewhere else.
    private func insertionBar(_ slot: PanelSlot, axis: Axis) -> some View {
        let active = drag.target?.slot == slot
        let offset = resolver.barOffset(
            for: drag.target?.index ?? 0, in: slot, barThickness: insertionBarThickness)
        return ZStack(alignment: axis == .vertical ? .top : .leading) {
            palette.accent.opacity(0.10)
            Capsule()
                .fill(palette.accent)
                .frame(width: axis == .vertical ? nil : insertionBarThickness,
                       height: axis == .vertical ? insertionBarThickness : nil)
                .padding(axis == .vertical ? .horizontal : .vertical, 5)
                .shadow(color: palette.accent.opacity(0.5), radius: 5)
                .offset(x: axis == .vertical ? 0 : offset,
                        y: axis == .vertical ? offset : 0)
        }
        .opacity(active ? 1 : 0)
        .allowsHitTesting(false)
        .animation(PanelMotion.indicator(reduceMotion), value: drag.target)
    }

    /// A thin strip along an empty region's edge: it lights up when a panel is dragged
    /// toward that edge. Clicking it offers the same add menu as the toolbar, so panels
    /// are reachable without a drag too.
    private func edgeRail(_ slot: PanelSlot) -> some View {
        EdgeRail(slot: slot, thickness: railThickness, onAdd: { panels.add($0, to: slot) })
            .measureRegion(slot, into: $regionFrames)
    }

    // MARK: - Sizing

    /// The floor a pane can be squeezed to by its neighbour's divider. Down a column that's
    /// enough to keep its two-line header intact; across the bottom row it's the width below
    /// which the title and status line start truncating, which is what makes a pane
    /// unidentifiable rather than merely small.
    private func minimumPaneLength(_ axis: Axis) -> CGFloat {
        axis == .vertical ? 92 : 180
    }

    private func widthRange(_ container: CGSize) -> ClosedRange<CGFloat> {
        160 ... max(200, container.width * 0.5)
    }
    private func heightRange(_ container: CGSize) -> ClosedRange<CGFloat> {
        120 ... max(160, container.height * 0.6)
    }
    private func clampWidth(_ value: CGFloat, container: CGSize) -> CGFloat {
        value.clamped(to: widthRange(container))
    }
    private func clampHeight(_ value: CGFloat, container: CGSize) -> CGFloat {
        value.clamped(to: heightRange(container))
    }

    // MARK: - Toolbar

    private var panelsMenu: some View {
        Menu {
            Section("Add panel") {
                ForEach(PanelKind.allCases) { kind in
                    Menu {
                        ForEach(PanelSlot.allCases, id: \.self) { slot in
                            Button { panels.add(kind, to: slot) } label: {
                                Label(slot.label, systemImage: slot.systemImage)
                            }
                        }
                    } label: {
                        Label(kind.title, systemImage: kind.systemImage)
                    }
                }
            }
            if !panels.saved.isEmpty {
                Section("Layouts") {
                    ForEach(panels.saved) { saved in
                        Button { panels.apply(saved) } label: {
                            layoutMenuLabel(saved)
                        }
                    }
                }
            }
            Section {
                Button("Save Current Layout…", systemImage: "square.and.arrow.down") {
                    layoutNameDraft = ""
                    savingLayout = true
                }
                Button("Manage Layouts…", systemImage: "slider.horizontal.3") {
                    managingLayouts = true
                }
                if !layout.left.isEmpty || !layout.right.isEmpty || !layout.bottom.isEmpty {
                    Button("Clear Panels", systemImage: "xmark.rectangle", role: .destructive) {
                        panels.clearAll()
                    }
                }
            }
        } label: {
            Label("Panels", systemImage: "rectangle.split.3x1")
        }
        .tint(.white)
        .help("Add panels around the chat, or apply a saved layout")
    }

    @ViewBuilder
    private func layoutMenuLabel(_ saved: PanelLayout) -> some View {
        let isDefault = panels.defaultLayoutID == saved.id
        let isActive = panels.matchesWorking(saved)
        if isActive {
            Label(saved.name, systemImage: "checkmark")
        } else if isDefault {
            Label("\(saved.name)  (default)", systemImage: "house")
        } else {
            Text(saved.name)
        }
    }
}

// MARK: - Region measurement

extension View {
    /// Publish this region's frame in the paneling root's coordinate space, so the root can
    /// hit-test a drag against it.
    fileprivate func measureRegion(
        _ slot: PanelSlot, into frames: Binding<[PanelSlot: CGRect]>
    ) -> some View {
        onGeometryChange(for: CGRect.self) {
            $0.frame(in: .named(PanelDragState.rootSpace))
        } action: {
            frames.wrappedValue[slot] = $0
        }
    }
}

// MARK: - Drag preview

/// The pane that follows the cursor during a drag: the real header — icon, title, status
/// line — over an empty body, at the pane's own proportions, lifted off the window with a
/// shadow and outlined in the accent so it reads as "in the air".
private struct PanelDragPreview: View {
    let kind: PanelKind
    let subtitle: String?
    let size: CGSize

    @Environment(\.appPalette) private var palette

    private let corner: CGFloat = 10

    var body: some View {
        VStack(spacing: 0) {
            HStack(alignment: .top, spacing: 8) {
                VStack(alignment: .leading, spacing: 2) {
                    Text(kind.title).font(.headline)
                    Text(subtitle ?? " ")
                        .font(.caption).foregroundStyle(.secondary).lineLimit(1)
                }
                Spacer(minLength: 0)
                Image(systemName: kind.systemImage)
                    .font(.headline).foregroundStyle(.secondary)
            }
            .padding(10)
            Divider()
            AppTheme.background
                .overlay {
                    Image(systemName: kind.systemImage)
                        .font(.system(size: 28))
                        .foregroundStyle(.secondary.opacity(0.3))
                }
        }
        .frame(width: size.width, height: size.height)
        .background(AppTheme.surface)
        .clipShape(.rect(cornerRadius: corner))
        .overlay {
            RoundedRectangle(cornerRadius: corner)
                .strokeBorder(palette.accent.opacity(0.75), lineWidth: 1.5)
        }
        .shadow(color: .black.opacity(0.4), radius: 18, y: 10)
        .opacity(0.95)
    }
}

// MARK: - Edge rail

/// The slim strip shown along an empty region: it looks like an ordinary split
/// divider (a hairline against the chat) until you hover or drag a panel over it,
/// when it reveals a short line (vertical on the side columns, horizontal on the
/// bottom row) and tints — so the chat reads as flush to the window edge
/// rather than boxed in by gutters. Clicking offers the same add menu as the
/// toolbar; a pane dragged toward its edge lands here.
private struct EdgeRail: View {
    let slot: PanelSlot
    let thickness: CGFloat
    let onAdd: (PanelKind) -> Void

    @State private var hovering = false
    @Environment(\.appPalette) private var palette
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(PanelDragState.self) private var drag

    private var isVerticalStrip: Bool { slot != .bottom }
    /// The drag would land here on release. Resolved by the paneling root against the
    /// window's edge bands, not by this 12pt strip — nobody can aim at a hairline.
    private var targeted: Bool { drag.target?.slot == slot }
    private var active: Bool { targeted || hovering }
    /// A drag is in flight somewhere in the window. Every empty rail leans out and
    /// brightens a little, so the regions a pane *could* go to are visible before the
    /// cursor reaches one — otherwise an empty region is a 12pt hairline the user has to
    /// already know about.
    private var armed: Bool { drag.dragging != nil }

    /// The rail widens as it becomes relevant: a hairline at rest, a little proud while
    /// a drag is live, and a clear gutter once the cursor is actually over it.
    private var width: CGFloat {
        if targeted { thickness + 10 } else if armed { thickness + 4 } else { thickness }
    }

    /// The edge of the strip that faces the chat, where the hairline divider sits.
    private var hairlineAlignment: Alignment {
        switch slot {
        case .left: .trailing
        case .right: .leading
        case .bottom: .top
        }
    }

    var body: some View {
        Menu {
            ForEach(PanelKind.allCases) { kind in
                Button { onAdd(kind) } label: { Label(kind.title, systemImage: kind.systemImage) }
            }
        } label: {
            ZStack {
                (targeted ? palette.accent.opacity(0.25) : Color.clear)
                Capsule()
                    .fill(active ? palette.accent : Color.secondary)
                    .frame(width: isVerticalStrip ? 2 : (active ? 30 : 18),
                           height: isVerticalStrip ? (active ? 30 : 18) : 2)
                    .opacity(active ? 1 : (armed ? 0.7 : 0.35))
            }
            .overlay(alignment: hairlineAlignment) {
                Rectangle().fill(AppTheme.hairline)
                    .frame(width: isVerticalStrip ? 1 : nil, height: isVerticalStrip ? nil : 1)
            }
            .contentShape(Rectangle())
        }
        .menuStyle(.borderlessButton)
        .menuIndicator(.hidden)
        .frame(width: isVerticalStrip ? width : nil,
               height: isVerticalStrip ? nil : width)
        .onHover { hovering = $0 }
        .help("Drop a panel here, or click to add one")
        .animation(PanelMotion.lift(reduceMotion), value: [active, armed])
    }
}

// MARK: - Resize handle

/// A draggable divider that adjusts a region's width or height. `sign` flips the
/// drag direction so the same control works whether the region is leading
/// (handle trails it) or trailing (handle leads it).
///
/// The gesture is measured in *global* coordinates on purpose: the handle moves as
/// the region resizes, so a local-space `translation` would be measured against a
/// frame that's shifting under it — a feedback loop that makes the drag jitter. A
/// global translation is independent of the handle's own movement, so the drag
/// tracks the cursor smoothly.
private struct ResizeHandle: View {
    enum Axis { case horizontal, vertical }
    let axis: Axis
    let sign: CGFloat
    let value: CGFloat
    let range: ClosedRange<CGFloat>
    let onChange: (CGFloat) -> Void
    let onCommit: () -> Void

    @State private var base: CGFloat?
    @State private var hovering = false
    @State private var dragging = false
    @Environment(\.appPalette) private var palette
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    /// The divider thickens and picks up the accent while it's in play, so it reads as a
    /// control rather than as the hairline it otherwise pretends to be.
    private var engaged: Bool { hovering || dragging }

    var body: some View {
        Rectangle()
            .fill(Color.clear)
            .overlay(
                Rectangle().fill(engaged ? palette.accent : AppTheme.hairline)
                    .frame(width: axis == .horizontal ? (engaged ? 3 : 1) : nil,
                           height: axis == .vertical ? (engaged ? 3 : 1) : nil))
            .contentShape(Rectangle())
            .onHover { inside in
                hovering = inside
                if inside {
                    (axis == .horizontal ? NSCursor.resizeLeftRight : NSCursor.resizeUpDown).set()
                } else {
                    NSCursor.arrow.set()
                }
            }
            // Keyed on `engaged` only: the divider's own position must never animate, or
            // it would lag the cursor it is being dragged by.
            .animation(PanelMotion.hover(reduceMotion), value: engaged)
            .gesture(
                DragGesture(minimumDistance: 1, coordinateSpace: .global)
                    .onChanged { g in
                        let start = base ?? value
                        if base == nil { base = value }
                        dragging = true
                        let raw = axis == .horizontal ? g.translation.width : g.translation.height
                        onChange((start + sign * raw).clamped(to: range))
                    }
                    .onEnded { _ in base = nil; dragging = false; onCommit() }
            )
    }
}

extension Comparable {
    func clamped(to range: ClosedRange<Self>) -> Self {
        min(max(self, range.lowerBound), range.upperBound)
    }
}

extension Collection {
    /// The element at `index`, or nil when it's out of range — for reading the pane lengths
    /// alongside the panes themselves without indexing off the end if the two ever disagree.
    fileprivate subscript(safe index: Index) -> Element? {
        indices.contains(index) ? self[index] : nil
    }
}
