using VPlasma.App;
using VRio.Core.Device;
using VRio.Core.Panel;
using VRio.Core.Protocol;

namespace VRio.App;

/// <summary>
/// The interactive cockpit control panel. Visually it mirrors RIOJoy's profile
/// editor map (same functional groups, cell geometry, and lamp colours), but
/// here the cells are the <em>physical controls</em>: pressing one emits a
/// button/keypad packet on the wire, and the fill shade tracks the lamp state
/// the host last commanded (including flash modes). The encoder strip is live
/// too — drag the X/Y box and the Z/L/R gauges to move the analog axes. The
/// gauges span each axis' realistic hardware travel (<see cref="RioAxisRange"/>),
/// not the full 14-bit wire range: Z/L/R rest at raw 0 at the gauge bottom and
/// fill toward full travel (negative on the wire for the throttle, positive
/// for the pedals), and the stick covers ±80 around its center.
///
/// <para>The strip's left slot hosts the built-in plasma glass: MainForm parks
/// its <see cref="PlasmaCanvas"/> child at <see cref="PlasmaSlot"/>, and the
/// strip height and gauge positions derive from the glass size.</para>
///
/// <para>Left-click = momentary press (release on mouse-up). Right-click =
/// latch the button down / release it (handy for testing holds).</para>
/// </summary>
internal sealed class PanelCanvas : Control
{
    // Cell geometry — identical to RIOJoy's PanelView so the two panels align.
    private const int CellW = 66;
    private const int CellH = 34;

    private static readonly IReadOnlyList<PanelButton> AllButtons = CockpitLayout.Buttons();

    // First grid row any group occupies (the shared layout leaves row 0
    // empty). Rendering shifts all rows up by this, so the button grid starts
    // right under the encoder strip instead of a blank cell row below it.
    private static readonly int FirstRow = CockpitLayout.Groups.Min(g => g.OriginRow);

    // Encoder-strip geometry. The built-in plasma glass parks at the strip's
    // left edge (MainForm adds its PlasmaCanvas as a child at PlasmaSlot),
    // the axis gauges — Z, the pedal gauges L / R, and the X/Y stick box —
    // hug the grid's right edge, and the green status text fills the gap
    // between them. The strip is exactly as tall as the glass.
    internal const int PlasmaDotPitch = 3; // 2 px dots: the 128×32 glass fits the strip
    internal static readonly Size PlasmaSize = PlasmaCanvas.SizeFor(PlasmaDotPitch);
    private const int StripTop = 6;
    internal static readonly Point PlasmaSlot = new(6, StripTop);
    private static readonly int StripH = PlasmaSize.Height;
    private static readonly int TopStrip = StripTop + StripH + 6; // button grid starts under the strip
    private const int Bar = 30;
    private static readonly int BaseX = GridWidth() - 6 - 200; // the gauge cluster spans 200 px, right-aligned
    private static readonly Rectangle BoxZ = new(BaseX, StripTop, Bar, StripH);
    private static readonly Rectangle BoxL = new(BaseX + 34, StripTop, Bar, StripH);
    private static readonly Rectangle BoxR = new(BaseX + 34 + 82 - Bar, StripTop, Bar, StripH);
    private static readonly Rectangle BoxXY = new(BaseX + 120, StripTop, 80, StripH);

    private readonly System.Windows.Forms.Timer _flashTimer = new() { Interval = 100 };
    private bool _wasFlashing;

    private readonly HashSet<int> _latched = new();
    private readonly HashSet<int> _externalHeld = new();
    private int? _mouseDownAddress;

    private RioAxis? _dragAxis;   // Z / L / R gauge drag
    private bool _dragStick;      // X/Y box drag

    private string _statusText = string.Empty;

    public PanelCanvas()
    {
        DoubleBuffered = true;
        BackColor = Color.FromArgb(28, 28, 28);
        Size = GridSize();

        _flashTimer.Tick += (_, _) =>
        {
            bool flashing = AnyLampFlashing();
            if (flashing || _wasFlashing)
                Invalidate();
            _wasFlashing = flashing;
        };
        _flashTimer.Start();
    }

    /// <summary>Current lamp-state byte for an address (from the device).</summary>
    public Func<int, byte>? LampProvider { get; set; }

    /// <summary>Current raw value of an axis (from the device).</summary>
    public Func<RioAxis, short>? AxisProvider { get; set; }

    /// <summary>
    /// Axis value as transmitted to the host (joystick Y sign flip applied).
    /// The text readout shows this; the dot and gauges track physical state.
    /// </summary>
    public Func<RioAxis, short>? WireAxisProvider { get; set; }

    /// <summary>The user pressed a panel control.</summary>
    public event Action<int>? AddressPressed;

    /// <summary>The user released a panel control.</summary>
    public event Action<int>? AddressReleased;

    /// <summary>The user dragged an axis to a new raw value.</summary>
    public event Action<RioAxis, short>? AxisMoved;

    /// <summary>When true (default), the X/Y stick snaps back to center on release.</summary>
    public bool StickSpringsBack { get; set; } = true;

    /// <summary>Green status text drawn in the strip area (the mockup's status panel).</summary>
    public string StatusText
    {
        get => _statusText;
        set
        {
            if (_statusText == value) return;
            _statusText = value;
            Invalidate();
        }
    }

    protected override void Dispose(bool disposing)
    {
        if (disposing) _flashTimer.Dispose();
        base.Dispose(disposing);
    }

    // Split from GridSize so the static gauge layout can use the width
    // without touching TopStrip (which initializes after AllButtons).
    private static int GridWidth()
    {
        int maxCol = 0;
        foreach (PanelButton b in AllButtons)
            if (b.Col > maxCol) maxCol = b.Col;
        return (maxCol + 1) * CellW + 6;
    }

    private static Size GridSize()
    {
        int maxRow = 0;
        foreach (PanelButton b in AllButtons)
            if (b.Row > maxRow) maxRow = b.Row;
        // The last used row plus a small margin under the lowest group frame.
        return new Size(GridWidth(), TopStrip + (maxRow - FirstRow + 1) * CellH + 6);
    }

    private static Rectangle Cell(int col, int row) =>
        new(col * CellW + 2, TopStrip + (row - FirstRow) * CellH + 2, CellW - 4, CellH - 4);

    // ---- Painting ----------------------------------------------------------

    protected override void OnPaint(PaintEventArgs e)
    {
        Graphics g = e.Graphics;
        g.Clear(Color.FromArgb(28, 28, 28));

        using var titleFont = new Font("Segoe UI", 8f, FontStyle.Bold);
        using var btnFont = new Font("Segoe UI", 7.5f);
        using var subFont = new Font("Segoe UI", 6.75f);
        using var groupPen = new Pen(Color.FromArgb(80, 80, 80));

        DrawEncoderStrip(g, titleFont, subFont);

        // Group frames + titles.
        foreach (PanelGroup grp in CockpitLayout.Groups)
        {
            var frame = new Rectangle(
                grp.OriginCol * CellW + 1,
                TopStrip + (grp.OriginRow - FirstRow) * CellH + 1,
                grp.Cols * CellW,
                (grp.Rows + 1) * CellH);
            g.DrawRectangle(groupPen, frame);
            TextRenderer.DrawText(g, grp.Title, titleFont,
                new Rectangle(frame.X + 2, frame.Y, frame.Width - 2, CellH), Color.Gainsboro,
                TextFormatFlags.Left | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis);
        }

        const TextFormatFlags oneLine = TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter |
                                        TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix;
        int tick = Environment.TickCount;

        foreach (PanelButton b in AllButtons)
        {
            Rectangle r = Cell(b.Col, b.Row);
            bool held = IsHeld(b.Address);
            bool yellow = b.Group.Title is "Secondary" or "Screen";

            // Lamp shade: locally held renders bright (a real pressed cap floods
            // bright); otherwise the host-commanded lamp state decides, with the
            // flash bits blinking between the commanded brightness and off.
            LampBrightness shade = LampBrightness.Off;
            if (b.LampCapable)
            {
                byte state = LampProvider?.Invoke(b.Address) ?? 0;
                shade = RioLampState.Brightness(state);
                if (shade != LampBrightness.Off && !FlashPhaseOn(RioLampState.Flash(state), tick))
                    shade = LampBrightness.Off;
            }
            if (held)
                shade = LampBrightness.Bright;

            Color fill = !b.LampCapable
                ? (held ? Color.FromArgb(205, 228, 255)                 // keypad — bright blue when pressed
                        : Color.FromArgb(150, 182, 226))                // keypad — neutral blue
                : yellow
                    ? shade switch
                    {
                        LampBrightness.Bright => Color.FromArgb(245, 210, 60),
                        LampBrightness.Dim => Color.FromArgb(140, 118, 38),
                        _ => Color.FromArgb(70, 60, 24),
                    }
                    : shade switch
                    {
                        LampBrightness.Bright => Color.FromArgb(230, 70, 70),
                        LampBrightness.Dim => Color.FromArgb(120, 50, 50),
                        _ => Color.FromArgb(64, 40, 40),
                    };
            using (var fb = new SolidBrush(fill)) g.FillRectangle(fb, r);

            Color fg = (b.LampCapable && !yellow) ? Color.White : Color.Black;
            string hex = $"{b.Address:X2}";
            string? label = CockpitLayout.PhysicalLabel(b.Address);

            if (label is not null && b.Group.Kind != PanelGroupKind.Keypad)
            {
                TextRenderer.DrawText(g, label, btnFont, new Rectangle(r.X, r.Y + 1, r.Width, r.Height / 2), fg,
                    TextFormatFlags.HorizontalCenter | TextFormatFlags.Top | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
                TextRenderer.DrawText(g, hex, subFont, new Rectangle(r.X, r.Bottom - r.Height / 2 - 1, r.Width, r.Height / 2), fg,
                    TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom | TextFormatFlags.EndEllipsis | TextFormatFlags.NoPrefix);
            }
            else
            {
                TextRenderer.DrawText(g, label ?? hex, btnFont, r, fg, oneLine);
            }

            if (_latched.Contains(b.Address))
            {
                using var latch = new Pen(Color.Gold, 2f);
                g.DrawRectangle(latch, r.X, r.Y, r.Width - 1, r.Height - 1);
            }
            else if (held)
            {
                using var hi = new Pen(Color.White, 2f);
                g.DrawRectangle(hi, r.X, r.Y, r.Width - 1, r.Height - 1);
            }
        }
    }

    private bool IsHeld(int address) =>
        _mouseDownAddress == address || _latched.Contains(address) || _externalHeld.Contains(address);

    /// <summary>
    /// Mark an address held/released by a non-mouse source (keyboard or
    /// gamepad via the input router) so it lights up like a click.
    /// </summary>
    public void SetExternalHeld(int address, bool held)
    {
        if (held ? _externalHeld.Add(address) : _externalHeld.Remove(address))
            Invalidate();
    }

    private static bool FlashPhaseOn(LampFlash flash, int tick)
    {
        int halfPeriod = flash switch
        {
            LampFlash.FlashSlow => 500,
            LampFlash.FlashMed => 250,
            LampFlash.FlashFast => 125,
            _ => 0,
        };
        return halfPeriod == 0 || tick / halfPeriod % 2 == 0;
    }

    private bool AnyLampFlashing()
    {
        if (LampProvider is not { } lamps)
            return false;
        for (int a = 0; a < RioAddressSpace.LampCount; a++)
        {
            byte state = lamps(a);
            if (RioLampState.Flash(state) != LampFlash.Solid && RioLampState.Brightness(state) != LampBrightness.Off)
                return true;
        }
        return false;
    }

    private void DrawEncoderStrip(Graphics g, Font labelFont, Font valueFont)
    {
        using var pen = new Pen(Color.FromArgb(120, 160, 120));
        using var bg = new SolidBrush(Color.FromArgb(20, 40, 20));
        using var fillBrush = new SolidBrush(Color.FromArgb(90, 190, 90));

        short Axis(RioAxis a) => AxisProvider?.Invoke(a) ?? (short)0;

        // Vertical gauges: Z (throttle) and the pedals L / R. Each rests at
        // raw 0 (gauge bottom) and fills toward full travel at the top.
        DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxZ, "Z", Axis(RioAxis.Throttle), RioAxisRange.ThrottleFull);
        DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxL, "L", Axis(RioAxis.LeftPedal), RioAxisRange.PedalFull);
        DrawVGauge(g, pen, bg, fillBrush, labelFont, BoxR, "R", Axis(RioAxis.RightPedal), RioAxisRange.PedalFull);

        // X/Y stick box with a crosshair at the current position.
        g.FillRectangle(bg, BoxXY);
        g.DrawRectangle(pen, BoxXY);
        using (var mid = new Pen(Color.FromArgb(50, 90, 50)))
        {
            g.DrawLine(mid, BoxXY.X, BoxXY.Y + BoxXY.Height / 2, BoxXY.Right, BoxXY.Y + BoxXY.Height / 2);
            g.DrawLine(mid, BoxXY.X + BoxXY.Width / 2, BoxXY.Y, BoxXY.X + BoxXY.Width / 2, BoxXY.Bottom);
        }
        PointF stick = StickToPoint(Axis(RioAxis.JoystickX), Axis(RioAxis.JoystickY));
        g.FillEllipse(fillBrush, stick.X - 4, stick.Y - 4, 8, 8);
        TextRenderer.DrawText(g, "X / Y", labelFont,
            new Rectangle(BoxXY.X, BoxXY.Bottom - 16, BoxXY.Width, 14), Color.Gainsboro,
            TextFormatFlags.HorizontalCenter | TextFormatFlags.Bottom);

        // The mockup's green status/instruction area, between the plasma glass
        // and the gauges; the live axis readout sits directly under the status
        // lines (painted per frame, so drags track).
        var statusRect = new Rectangle(
            PlasmaSlot.X + PlasmaSize.Width + 12, StripTop,
            BoxZ.Left - PlasmaSlot.X - PlasmaSize.Width - 24, StripH);
        if (statusRect.Width > 60)
        {
            using var statusFont = new Font("Consolas", 8f);
            var green = Color.FromArgb(120, 220, 120);
            int readoutTop = statusRect.Y;
            if (_statusText.Length > 0)
            {
                TextRenderer.DrawText(g, _statusText, statusFont, statusRect, green,
                    TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.WordBreak);
                readoutTop += TextRenderer.MeasureText(g, _statusText, statusFont,
                    new Size(statusRect.Width, 0), TextFormatFlags.WordBreak).Height + 2;
            }

            short WireAxis(RioAxis a) => WireAxisProvider?.Invoke(a) ?? Axis(a);
            string readout =
                $"Z {WireAxis(RioAxis.Throttle),4}  L {WireAxis(RioAxis.LeftPedal),3}  R {WireAxis(RioAxis.RightPedal),3}\n" +
                $"X {WireAxis(RioAxis.JoystickX),3}  Y {WireAxis(RioAxis.JoystickY),3}";
            TextRenderer.DrawText(g, readout, statusFont,
                new Rectangle(statusRect.X, readoutTop, statusRect.Width, statusRect.Bottom - readoutTop), green,
                TextFormatFlags.Left | TextFormatFlags.Top);
        }
    }

    private static void DrawVGauge(Graphics g, Pen pen, Brush bg, Brush fill, Font labelFont,
        Rectangle box, string label, short value, int full)
    {
        g.FillRectangle(bg, box);
        // Fill upward from the raw-0 rest position at the bottom, proportional
        // to how far the control has travelled toward `full` (either sign).
        float norm = Math.Max(0f, Math.Min(1f, value / (float)full));
        int h = (int)(norm * (box.Height - 2));
        if (h > 0)
            g.FillRectangle(fill, box.X + 1, box.Bottom - 1 - h, box.Width - 2, h);
        g.DrawRectangle(pen, box);
        TextRenderer.DrawText(g, label, labelFont, box, Color.Gainsboro,
            TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter);
    }

    private static PointF StickToPoint(short x, short y)
    {
        // Raw X runs opposite the screen direction: stick right = negative on
        // the wire (RIOJoy's calibrator maps negative samples to the high half
        // of its output axis), so mirror the value for display.
        float nx = Norm(-x, RioAxisRange.JoystickExtent);
        float ny = Norm(y, RioAxisRange.JoystickExtent);
        return new PointF(
            BoxXY.X + 1 + nx * (BoxXY.Width - 2),
            BoxXY.Bottom - 1 - ny * (BoxXY.Height - 2)); // up = positive Y
    }

    /// <summary>Raw axis value → 0..1 across its ±extent travel, clamped.</summary>
    private static float Norm(int value, int extent) =>
        Math.Max(0f, Math.Min(1f, (value + extent) / (2f * extent)));

    // ---- Interaction -------------------------------------------------------

    protected override void OnMouseDown(MouseEventArgs e)
    {
        base.OnMouseDown(e);
        Focus();

        // Axis gauges first (they live above the button grid).
        if (e.Button == MouseButtons.Left)
        {
            if (BoxXY.Contains(e.Location))
            {
                _dragStick = true;
                Capture = true;
                UpdateStick(e.Location);
                return;
            }
            RioAxis? gauge = HitGauge(e.Location);
            if (gauge is { } axis)
            {
                _dragAxis = axis;
                Capture = true;
                UpdateGauge(axis, e.Location);
                return;
            }
        }

        PanelButton? hit = HitButton(e.Location);
        if (hit is null)
            return;

        if (e.Button == MouseButtons.Right)
        {
            // Latch toggle: press-and-hold without keeping the mouse down.
            if (_latched.Remove(hit.Address))
                AddressReleased?.Invoke(hit.Address);
            else
            {
                _latched.Add(hit.Address);
                AddressPressed?.Invoke(hit.Address);
            }
            Invalidate();
        }
        else if (e.Button == MouseButtons.Left)
        {
            if (_latched.Remove(hit.Address))
            {
                // Left-clicking a latched button releases it.
                AddressReleased?.Invoke(hit.Address);
                Invalidate();
                return;
            }
            _mouseDownAddress = hit.Address;
            AddressPressed?.Invoke(hit.Address);
            Invalidate();
        }
    }

    protected override void OnMouseMove(MouseEventArgs e)
    {
        base.OnMouseMove(e);
        if (_dragStick)
            UpdateStick(e.Location);
        else if (_dragAxis is { } axis)
            UpdateGauge(axis, e.Location);
    }

    protected override void OnMouseUp(MouseEventArgs e)
    {
        base.OnMouseUp(e);

        if (_dragStick)
        {
            _dragStick = false;
            Capture = false;
            if (StickSpringsBack)
            {
                AxisMoved?.Invoke(RioAxis.JoystickX, 0);
                AxisMoved?.Invoke(RioAxis.JoystickY, 0);
            }
            return;
        }
        if (_dragAxis is not null)
        {
            _dragAxis = null;
            Capture = false;
            return;
        }

        ReleaseMomentary();
    }

    protected override void OnMouseCaptureChanged(EventArgs e)
    {
        base.OnMouseCaptureChanged(e);
        _dragStick = false;
        _dragAxis = null;
        ReleaseMomentary();
    }

    private void ReleaseMomentary()
    {
        if (_mouseDownAddress is not { } addr)
            return;
        _mouseDownAddress = null;
        AddressReleased?.Invoke(addr);
        Invalidate();
    }

    private static PanelButton? HitButton(Point p)
    {
        foreach (PanelButton b in AllButtons)
            if (Cell(b.Col, b.Row).Contains(p))
                return b;
        return null;
    }

    private static RioAxis? HitGauge(Point p)
    {
        if (BoxZ.Contains(p)) return RioAxis.Throttle;
        if (BoxL.Contains(p)) return RioAxis.LeftPedal;
        if (BoxR.Contains(p)) return RioAxis.RightPedal;
        return null;
    }

    private void UpdateStick(Point p)
    {
        // Dragging right lowers raw X — the wire sign is opposite the screen
        // direction (see StickToPoint).
        short x = FromNorm((BoxXY.Right - p.X) / (float)BoxXY.Width, RioAxisRange.JoystickExtent);
        short y = FromNorm((BoxXY.Bottom - p.Y) / (float)BoxXY.Height, RioAxisRange.JoystickExtent);
        AxisMoved?.Invoke(RioAxis.JoystickX, x);
        AxisMoved?.Invoke(RioAxis.JoystickY, y);
    }

    private void UpdateGauge(RioAxis axis, Point p)
    {
        Rectangle box = axis switch
        {
            RioAxis.Throttle => BoxZ,
            RioAxis.LeftPedal => BoxL,
            _ => BoxR,
        };
        // Gauge bottom = rest (raw 0), top = full travel (sign per axis).
        float norm = Math.Max(0f, Math.Min(1f, (box.Bottom - p.Y) / (float)box.Height));
        AxisMoved?.Invoke(axis, (short)(norm * RioAxisRange.Full(axis)));
    }

    private static short FromNorm(float norm, int extent)
    {
        norm = Math.Max(0f, Math.Min(1f, norm));
        return (short)(-extent + norm * 2 * extent);
    }
}
