Code/HitShapes/SlotDispatcher.cs
using System;
using Sandbox;
using Sandbox.UI;

namespace HitShapes;

/// <summary>Holds an <see cref="IHitShape"/> and tracks per-slot enter/leave/click transitions.</summary>
public sealed class SlotDispatcher
{
    readonly IHitShape _shape;
    readonly IGeometricHitShape? _geometric;
    int? _currentSlot;
    float? _lastAngleDeg;
    float? _lastDistanceNormalized;

    public SlotDispatcher(IHitShape shape)
    {
        _shape = shape ?? throw new ArgumentNullException(nameof(shape));
        _geometric = shape as IGeometricHitShape;
    }

    public IHitShape Shape => _shape;
    public int? CurrentSlot => _currentSlot;

    /// <summary>Cursor angle from the most recent hover update, in degrees,
    /// 0 = up, clockwise positive, [0, 360). Null when the cursor is outside
    /// the shape or the underlying <see cref="IHitShape"/> does not provide
    /// geometric state (only radial shapes do today).</summary>
    public float? LastAngleDeg => _lastAngleDeg;

    /// <summary>Cursor distance from center as a 0..1 fraction of the shape's
    /// outer radius, from the most recent hover update. Null when the cursor
    /// is outside the shape or the underlying <see cref="IHitShape"/> does not
    /// provide geometric state.</summary>
    public float? LastDistanceNormalized => _lastDistanceNormalized;

    public Action<int> OnSlotEnter { get; init; } = _ => { };
    public Action<int> OnSlotLeave { get; init; } = _ => { };
    public Action<int, MousePanelEvent> OnSlotClick { get; init; } = (_, _) => { };

    public void HandleMouseEnter(MousePanelEvent e) => UpdateHoverFrom(e);
    public void HandleMouseMove(MousePanelEvent e) => UpdateHoverFrom(e);

    /// <summary>Hover update using explicit receiver size. Safe under event bubbling.</summary>
    public void HandleMouseEnter(MousePanelEvent e, Panel receiver) => UpdateHoverAt(e.LocalPosition, receiver.Box.Rect.Size);
    public void HandleMouseMove(MousePanelEvent e, Panel receiver) => UpdateHoverAt(e.LocalPosition, receiver.Box.Rect.Size);

    public void HandleMouseLeave(MousePanelEvent e)
    {
        _lastAngleDeg = null;
        _lastDistanceNormalized = null;
        if (_currentSlot is int oldSlot)
        {
            _currentSlot = null;
            OnSlotLeave?.Invoke(oldSlot);
        }
    }

    public void HandleClick(MousePanelEvent e)
    {
        var size = e.Target?.Box.Rect.Size ?? Vector2.Zero;
        var slot = _shape.Resolve(e.LocalPosition, size);
        if (slot is int s) OnSlotClick?.Invoke(s, e);
    }

    /// <summary>Click using explicit receiver size. Safe under event bubbling.</summary>
    public void HandleClick(MousePanelEvent e, Panel receiver)
    {
        var slot = _shape.Resolve(e.LocalPosition, receiver.Box.Rect.Size);
        if (slot is int s) OnSlotClick?.Invoke(s, e);
    }

    /// <summary>Drive the hover state machine without a MousePanelEvent.</summary>
    public void UpdateHoverAt(Vector2 localPosition, Vector2 panelSize)
    {
        int? newSlot;
        if (_geometric is not null)
        {
            newSlot = _geometric.ResolveWithGeometry(localPosition, panelSize, out _lastAngleDeg, out _lastDistanceNormalized);
        }
        else
        {
            newSlot = _shape.Resolve(localPosition, panelSize);
            _lastAngleDeg = null;
            _lastDistanceNormalized = null;
        }

        if (newSlot == _currentSlot) return;

        var oldSlot = _currentSlot;
        _currentSlot = newSlot;
        if (oldSlot is int leaving) OnSlotLeave?.Invoke(leaving);
        if (newSlot is int entering) OnSlotEnter?.Invoke(entering);
    }

    /// <summary>Clear hover state. Call after panel recreation if the dispatcher survives it.</summary>
    public void Reset()
    {
        _currentSlot = null;
        _lastAngleDeg = null;
        _lastDistanceNormalized = null;
    }

    void UpdateHoverFrom(MousePanelEvent e)
    {
        var size = e.Target?.Box.Rect.Size ?? Vector2.Zero;
        UpdateHoverAt(e.LocalPosition, size);
    }
}