import AppKit
import Observation
import SwiftUI

/// Where a pane would land if it were dropped right now: which region, and which index
/// within that region's stack.
struct PanelDropTarget: Equatable, Hashable, Sendable {
    var slot: PanelSlot
    var index: Int
}

/// Resolves a cursor position to the place a dragged pane would land.
///
/// Split out of `PaneledChatView` as a plain value so the arithmetic — which is where the
/// off-by-one bugs live — can be reasoned about and exercised on its own, away from view
/// state and geometry readers.
struct PanelDropResolver {
    /// Each region's frame, in the paneling root's coordinate space.
    var regionFrames: [PanelSlot: CGRect]
    /// The whole paneling area, for the edge bands.
    var rootSize: CGSize
    /// Each region's panes as their shares of it, in order (see `PanelLayout.shares(in:)`).
    /// Shares rather than a plain count because panes are resizable: the boundaries a drop is
    /// measured against have to be the ones actually on screen, or the insertion bar drifts off
    /// the divider it is pointing at as soon as a column is split unevenly.
    var shares: [PanelSlot: [CGFloat]]

    /// How generous the bands along the window edges are. An *empty* region is only a
    /// 12–22pt rail, far too small to aim at mid-drag, so it claims a band instead.
    private var sideBand: CGFloat { max(72, rootSize.width * 0.14) }
    private var footBand: CGFloat { max(88, rootSize.height * 0.16) }

    private func count(_ slot: PanelSlot) -> Int { shares[slot]?.count ?? 0 }

    /// Where a pane released at `point` would land, or `nil` for "not over anything" —
    /// which is how releasing over the chat abandons a drag.
    ///
    /// A populated region claims the cursor anywhere inside its own frame. Otherwise the
    /// empty regions' edge bands are considered, and when two overlap in a corner the
    /// nearer edge wins.
    func target(at point: CGPoint) -> PanelDropTarget? {
        for slot in PanelSlot.allCases where count(slot) > 0 {
            if let frame = regionFrames[slot], frame.contains(point) {
                return PanelDropTarget(slot: slot, index: insertionIndex(at: point, in: frame, slot: slot))
            }
        }
        let candidates: [(slot: PanelSlot, distance: CGFloat)] = [
            (.left, point.x),
            (.right, rootSize.width - point.x),
            (.bottom, rootSize.height - point.y),
        ]
        let reachable = candidates.filter { slot, distance in
            count(slot) == 0 && distance >= 0 && distance < (slot == .bottom ? footBand : sideBand)
        }
        guard let nearest = reachable.min(by: { $0.distance < $1.distance }) else { return nil }
        return PanelDropTarget(slot: nearest.slot, index: 0)
    }

    /// Which index a pane released at `point` should take within `slot`'s stack.
    ///
    /// The rule is the usual "insert before or after whichever pane you're over", so the
    /// boundaries sit at pane *midpoints* and the outer halves round out to 0 and the count.
    /// Walked over the panes' real shares rather than assuming equal parts: a column split
    /// 70/30 has its boundary 70% of the way down, not halfway.
    func insertionIndex(at point: CGPoint, in frame: CGRect, slot: PanelSlot) -> Int {
        let parts = shares[slot] ?? []
        guard !parts.isEmpty else { return 0 }
        let vertical = slot != .bottom
        let span = vertical ? frame.height : frame.width
        guard span > 0 else { return parts.count }
        let offset = vertical ? point.y - frame.minY : point.x - frame.minX
        let position = offset / span
        var leading: CGFloat = 0
        for (index, share) in parts.enumerated() {
            if position < leading + share {
                return position < leading + share / 2 ? index : index + 1
            }
            leading += share
        }
        return parts.count
    }

    /// Where along `slot`'s stack the insertion bar for `index` sits, as a distance from
    /// the region's leading edge along its axis — the cumulative share of the panes before it.
    func barOffset(for index: Int, in slot: PanelSlot, barThickness: CGFloat) -> CGFloat {
        let parts = shares[slot] ?? []
        let vertical = slot != .bottom
        let size = regionFrames[slot]?.size ?? .zero
        let span = vertical ? size.height : size.width
        let leading = parts.prefix(max(index, 0)).reduce(0, +) * span
        return min(max(leading, 0), max(0, span - barThickness))
    }
}

/// A pane that has been picked up: what is moving, and where it is.
struct PanelLift: Equatable {
    var payload: PanelDrag
    /// The pane's header subtitle, so the floating preview can carry the same two lines
    /// the real header does.
    var subtitle: String?
    /// The size the pane occupied when it was picked up — the preview keeps its
    /// proportions so what you're dragging still reads as *that* pane.
    var paneSize: CGSize
    /// Cursor position, in the paneling root's coordinate space.
    var point: CGPoint
}

/// The window-wide state of an in-flight panel drag.
///
/// Panels are moved with a plain `DragGesture` rather than AppKit drag-and-drop. The
/// pasteboard route needs a declared uniform type identifier, and `com.nucleic.panel` is
/// declared nowhere — there is no `UTExportedTypeDeclarations` entry in any Info.plist,
/// and `make dev` / `make run` produce a bare SwiftPM executable with no bundle at all.
/// `UTType(exportedAs:)` happily hands back an *undeclared* type for it, which AppKit's
/// pasteboard matching then never resolves, so no region ever accepted a drop. A gesture
/// needs no type system at all: it stays inside the process, works identically in the
/// bare dev binary and the packaged app, and lets us draw the drag preview ourselves
/// instead of settling for whatever the system renders.
///
/// Kept as view state on `PaneledChatView` and handed down through the environment rather
/// than living in `PanelLayoutStore`: none of it is part of the arrangement, and none of
/// it should ever be persisted.
@MainActor
@Observable
final class PanelDragState {
    /// The coordinate space every panel drag is measured in — declared on the paneling
    /// root so a gesture on a pane header, the region frames it is tested against, and
    /// the floating preview all speak the same coordinates.
    /// Nonisolated so the geometry closures that read it — which SwiftUI requires to be
    /// `Sendable` — can name it without hopping actors.
    nonisolated static let rootSpace = "nucleic.panelRoot"

    /// The pane being dragged right now, or `nil` when nothing is in flight.
    private(set) var lift: PanelLift?
    /// Where that pane would land on release. `nil` over the chat — releasing there is
    /// how you abandon a drag.
    private(set) var target: PanelDropTarget?

    nonisolated init() {}

    var dragging: PanelDrag? { lift?.payload }

    /// Whether `instanceID` is the pane being dragged right now.
    func isDragging(_ instanceID: UUID) -> Bool {
        lift?.payload.instanceID == instanceID
    }

    func begin(_ payload: PanelDrag, subtitle: String?, paneSize: CGSize, at point: CGPoint) {
        lift = PanelLift(payload: payload, subtitle: subtitle, paneSize: paneSize, point: point)
        NSCursor.closedHand.set()
    }

    func move(to point: CGPoint, target: PanelDropTarget?) {
        lift?.point = point
        self.target = target
    }

    func end() {
        lift = nil
        target = nil
        NSCursor.arrow.set()
    }
}
