using VRio.Core.Protocol;

namespace VRio.Core.Device;

/// <summary>
/// The virtual RIO board: a pure (transport-free) protocol state machine that
/// behaves like the cockpit hardware on the wire. Feed it received bytes via
/// <see cref="OnReceived"/>; everything it wants to transmit is raised through
/// <see cref="Transmit"/>. The UI pokes it with <see cref="PressAddress"/>,
/// <see cref="SetAxis"/>, etc., and listens for <see cref="LampChanged"/> to
/// light its on-screen buttons.
///
/// <para>Wire behavior (mirroring what RIOJoy expects from the real board):</para>
/// <list type="bullet">
///   <item>ACKs every well-formed inbound packet, NAKs one with a bad checksum.</item>
///   <item>CheckRequest → TestModeChange ENTER, a BoardOk CheckReply per known
///   board (the self-test stream), then TestModeChange EXIT — the init
///   handshake the real board performs and the game waits (≤5s per step) on.</item>
///   <item>VersionRequest → VersionReply with the configured firmware version
///   (default 4.2, matching the real board's dumped EPROM).</item>
///   <item>AnalogRequest → AnalogReply with the current five axis values.</item>
///   <item>ResetRequest → re-zeroes the targeted axis (or all) like a
///   recalibration, and reports it via <see cref="ResetReceived"/>.</item>
///   <item>LampRequest → stores the lamp state and raises <see cref="LampChanged"/>.</item>
///   <item>A NAK from the PC re-sends the last event packet up to 4 times, then
///   gives up with a RESTART byte — the real v4.2 firmware's retry budget
///   (riojoy/rio-firmware/RIOv4_2-ANALYSIS.md, counters $3173-$3175 limit 4,
///   give-up at $D9D5 sends $FE).</item>
/// </list>
///
/// <para>With <see cref="EmulateReplyWedge"/> on, the give-up path also
/// reproduces the v4.2 firmware's orphaned reply-in-progress latch ($2521):
/// every subsequent AnalogRequest is silently dropped (still ACK'd — the RX
/// path stays alive) until a host ResetRequest arrives, mirroring the
/// mash-stress analog mute seen on real hardware. Useful for exercising
/// RIOJoy's no-analog recovery watchdog.</para>
///
/// All members are thread-safe; events may fire on the caller's thread.
/// </summary>
public sealed class VRioDevice
{
    // The real firmware retries a reply 4 times before giving up ($3173-$3175).
    private const int MaxResends = 4;

    private readonly object _gate = new();
    private readonly PacketParser _parser = new();
    private readonly short[] _axes = new short[5];
    private readonly byte[] _lamps = new byte[RioAddressSpace.LampCount];

    private byte[]? _lastEventPacket;
    private int _resends;
    private bool _analogMuted;

    /// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
    public byte VersionMajor { get; set; } = 4;

    /// <summary>Firmware version reported by VersionReply (real boards run 4.2).</summary>
    public byte VersionMinor { get; set; } = 2;

    /// <summary>
    /// The wire natively carries stick-up as negative (full up = −80 in
    /// AnalogReply); set this to send the physical direction (up = positive)
    /// instead. Only the host sees the difference — local state
    /// (<see cref="GetAxis"/>, the panel's dot) keeps the physical stick
    /// direction either way.
    /// </summary>
    public bool InvertJoystickY { get; set; }

    /// <summary>
    /// When true, retry exhaustion leaves the analog reply path wedged (the
    /// v4.2 latch-leak bug) until a host ResetRequest clears it.
    /// </summary>
    public bool EmulateReplyWedge { get; set; }

    /// <summary>True while the analog reply path is wedged (see <see cref="EmulateReplyWedge"/>).</summary>
    public bool AnalogWedged
    {
        get { lock (_gate) return _analogMuted; }
    }

    /// <summary>Bytes the device wants on the wire (already framed).</summary>
    public event Action<byte[]>? Transmit;

    /// <summary>A lamp changed: (address 0x00–0x47, raw lamp-state byte).</summary>
    public event Action<int, byte>? LampChanged;

    /// <summary>The PC asked for an axis reset/recalibration.</summary>
    public event Action<RioResetTarget>? ResetReceived;

    /// <summary>Axis values changed (reset or local set) — refresh gauges.</summary>
    public event Action? AxesChanged;

    /// <summary>Human-readable protocol log lines (analog polls excluded — see <see cref="AnalogRequests"/>).</summary>
    public event Action<string>? Logged;

    /// <summary>Count of AnalogRequests served (RIOJoy polls ~18×/s; logging each would drown the log).</summary>
    public long AnalogRequests { get; private set; }

    /// <summary>Count of AnalogRequests silently dropped while wedged.</summary>
    public long AnalogDropped { get; private set; }

    /// <summary>Count of packets received with a bad checksum (NAK'd).</summary>
    public long BadChecksums { get; private set; }

    // ---- Local (UI-facing) state ------------------------------------------

    /// <summary>Current raw value of an axis.</summary>
    public short GetAxis(RioAxis axis)
    {
        lock (_gate) return _axes[(int)axis];
    }

    /// <summary>
    /// Axis value as the next AnalogReply will carry it — same as
    /// <see cref="GetAxis"/> except joystick Y follows the wire convention
    /// (see <see cref="InvertJoystickY"/>).
    /// </summary>
    public short GetWireAxis(RioAxis axis)
    {
        short value = GetAxis(axis);
        return axis == RioAxis.JoystickY ? WireY(value) : value;
    }

    private short WireY(short y) => InvertJoystickY
        ? y
        : (short)Math.Min(AnalogCodec.Max, -y); // clamp: -Min (8192) is one past the 14-bit Max

    /// <summary>
    /// Move an axis. Values are clamped to the 14-bit signed range the wire
    /// can carry; the new value is returned by the next AnalogReply.
    /// </summary>
    public void SetAxis(RioAxis axis, int value)
    {
        short clamped = (short)Math.Max(AnalogCodec.Min, Math.Min(AnalogCodec.Max, value));
        lock (_gate) _axes[(int)axis] = clamped;
        AxesChanged?.Invoke();
    }

    /// <summary>Current lamp state byte for a button address (0x00–0x47).</summary>
    public byte GetLamp(int address)
    {
        if (!RioAddressSpace.IsButton(address)) return 0;
        lock (_gate) return _lamps[address];
    }

    /// <summary>
    /// Locally darken every lamp (a fresh board powers up dark; the host
    /// re-lights what it wants). Callers should refresh their display.
    /// </summary>
    public void ClearLamps()
    {
        lock (_gate) Array.Clear(_lamps, 0, _lamps.Length);
        Logged?.Invoke("Local: all lamps cleared");
    }

    // ---- Local inputs → wire events ---------------------------------------

    /// <summary>Press the input at a RIO address (button or keypad key).</summary>
    public void PressAddress(int address) => SendInputEvent(address, pressed: true);

    /// <summary>Release the input at a RIO address (button or keypad key).</summary>
    public void ReleaseAddress(int address) => SendInputEvent(address, pressed: false);

    /// <summary>Announce a test-mode change (0 = exit test mode).</summary>
    public void SendTestMode(byte mode)
    {
        SendEvent(PacketBuilder.TestModeChange((byte)(mode & 0x7F)));
        Logged?.Invoke($"TX TestModeChange mode={mode}");
    }

    /// <summary>
    /// Force the analog reply path into the wedged state right now (as if the
    /// retry give-up just leaked the latch) — a one-click way to watch the
    /// host's no-analog recovery kick in. A host ResetRequest clears it.
    /// </summary>
    public void WedgeAnalogNow()
    {
        lock (_gate) _analogMuted = true;
        Logged?.Invoke("Local: ANALOG WEDGED (v4.2 bug emulation) — waiting for a host reset");
    }

    private void SendInputEvent(int address, bool pressed)
    {
        byte[] packet;
        if (RioAddressSpace.IsButton(address))
        {
            packet = pressed
                ? PacketBuilder.ButtonPressed((byte)address)
                : PacketBuilder.ButtonReleased((byte)address);
        }
        else if (RioAddressSpace.IsKeypad(address))
        {
            (byte pad, byte index) = RioAddressSpace.ToKeypad(address);
            packet = pressed
                ? PacketBuilder.KeyPressed(pad, index)
                : PacketBuilder.KeyReleased(pad, index);
        }
        else
        {
            throw new ArgumentOutOfRangeException(nameof(address), $"0x{address:X2} is not a RIO input address.");
        }

        SendEvent(packet);
        Logged?.Invoke($"TX {(pressed ? "press" : "release")} 0x{address:X2}");
    }

    // Event packets (button/key/test) are remembered so a NAK can re-send them.
    private void SendEvent(byte[] packet)
    {
        lock (_gate)
        {
            _lastEventPacket = packet;
            _resends = 0;
        }
        Transmit?.Invoke(packet);
    }

    private void Send(byte[] packet) => Transmit?.Invoke(packet);

    private void SendControl(RioControl control) => Transmit?.Invoke(new[] { (byte)control });

    // ---- Wire → device -----------------------------------------------------

    /// <summary>Feed bytes received from the PC.</summary>
    public void OnReceived(byte[] data, int count)
    {
        for (int i = 0; i < count; i++)
        {
            bool produced;
            RioRxEvent ev;
            lock (_gate) produced = _parser.Feed(data[i], out ev);
            if (produced)
                HandleEvent(ev);
        }
    }

    private void HandleEvent(RioRxEvent ev)
    {
        switch (ev.Kind)
        {
            case RioRxKind.Packet when !ev.ChecksumValid:
                BadChecksums++;
                SendControl(RioControl.Nak);
                Logged?.Invoke($"RX {ev.Packet} — bad checksum, NAK'd");
                break;

            case RioRxKind.Packet:
                SendControl(RioControl.Ack);
                HandlePacket(ev.Packet);
                break;

            case RioRxKind.ControlByte:
                HandleControl(ev.Byte);
                break;

            case RioRxKind.FramingError:
                Logged?.Invoke($"RX framing error (0x{ev.Byte:X2} mid-packet) — resynced");
                break;
        }
    }

    private void HandlePacket(RioPacket packet)
    {
        byte[] p = packet.Payload;
        switch (packet.Command)
        {
            case RioCommand.CheckRequest:
                // Init handshake (verified against a real v4.2 board tap): the
                // host waits ≤5s for TestModeChange ENTER before anything else,
                // and sends no requests at all until the matching EXIT arrives.
                Logged?.Invoke("RX CheckRequest → test mode enter, all boards OK, exit");
                Send(PacketBuilder.TestModeChange(1));
                foreach ((byte number, string _) in RioAddressSpace.Boards)
                    Send(PacketBuilder.CheckReply(RioStatusType.BoardOk, number));
                Send(PacketBuilder.TestModeChange(0));
                break;

            case RioCommand.VersionRequest:
                Logged?.Invoke($"RX VersionRequest → {VersionMajor}.{VersionMinor}");
                Send(PacketBuilder.VersionReply(VersionMajor, VersionMinor));
                break;

            case RioCommand.AnalogRequest:
                short t, l, r, y, x;
                lock (_gate)
                {
                    if (_analogMuted)
                    {
                        // The wedged v4.2 board drops the request in reply
                        // generation ($D758) — the packet was still ACK'd above.
                        AnalogDropped++;
                        return;
                    }
                    AnalogRequests++;
                    t = _axes[(int)RioAxis.Throttle];
                    l = _axes[(int)RioAxis.LeftPedal];
                    r = _axes[(int)RioAxis.RightPedal];
                    y = _axes[(int)RioAxis.JoystickY];
                    x = _axes[(int)RioAxis.JoystickX];
                }
                Send(PacketBuilder.AnalogReply(t, l, r, WireY(y), x));
                break;

            case RioCommand.ResetRequest:
                var target = (RioResetTarget)p[0];
                ApplyReset(target);
                bool unwedged;
                lock (_gate)
                {
                    // The host reset/init handler ($C686) is what clears the
                    // real board's leaked reply latch — mirror that here.
                    unwedged = _analogMuted;
                    _analogMuted = false;
                }
                Logged?.Invoke($"RX ResetRequest {target} → re-zeroed" + (unwedged ? " (analog wedge cleared)" : ""));
                ResetReceived?.Invoke(target);
                break;

            case RioCommand.LampRequest:
                int lamp = p[0];
                byte state = p[1];
                if (RioAddressSpace.IsButton(lamp))
                {
                    lock (_gate) _lamps[lamp] = state;
                    Logged?.Invoke($"RX Lamp 0x{lamp:X2} = 0x{state:X2} ({RioLampState.Describe(state)})");
                    LampChanged?.Invoke(lamp, state);
                }
                else
                {
                    Logged?.Invoke($"RX LampRequest for unknown lamp 0x{lamp:X2} — ignored");
                }
                break;

            default:
                // A RIO→PC message arriving at the device end — echo it to the log.
                Logged?.Invoke($"RX unexpected {packet} — ignored");
                break;
        }
    }

    // On the real board a reset re-references the encoder at its current
    // position; the emulator's equivalent is snapping the value back to zero.
    private void ApplyReset(RioResetTarget target)
    {
        lock (_gate)
        {
            switch (target)
            {
                case RioResetTarget.Throttle: _axes[(int)RioAxis.Throttle] = 0; break;
                case RioResetTarget.LeftPedal: _axes[(int)RioAxis.LeftPedal] = 0; break;
                case RioResetTarget.RightPedal: _axes[(int)RioAxis.RightPedal] = 0; break;
                case RioResetTarget.VerticalJoystick: _axes[(int)RioAxis.JoystickY] = 0; break;
                case RioResetTarget.HorizontalJoystick: _axes[(int)RioAxis.JoystickX] = 0; break;
                default: Array.Clear(_axes, 0, _axes.Length); break; // general reset
            }
        }
        AxesChanged?.Invoke();
    }

    private void HandleControl(byte b)
    {
        switch ((RioControl)b)
        {
            case RioControl.Ack:
                lock (_gate)
                {
                    _lastEventPacket = null;
                    _resends = 0;
                }
                break;

            case RioControl.Nak:
                byte[]? resend = null;
                bool giveUp = false;
                lock (_gate)
                {
                    if (_lastEventPacket is null)
                    {
                        // nothing pending; stray NAK
                    }
                    else if (_resends < MaxResends)
                    {
                        _resends++;
                        resend = _lastEventPacket;
                    }
                    else
                    {
                        // Retry budget exhausted: the real firmware sends RESTART
                        // ($D9D5) and tears down — leaking its reply latch.
                        _lastEventPacket = null;
                        _resends = 0;
                        giveUp = true;
                        if (EmulateReplyWedge)
                            _analogMuted = true;
                    }
                }
                if (resend is not null)
                {
                    Logged?.Invoke("RX NAK — re-sending last event");
                    Transmit?.Invoke(resend);
                }
                else if (giveUp)
                {
                    Logged?.Invoke(EmulateReplyWedge
                        ? "RX NAK — retries exhausted, RESTART sent; ANALOG WEDGED (v4.2 bug) until a host reset"
                        : "RX NAK — retries exhausted, RESTART sent");
                    SendControl(RioControl.Restart);
                }
                else
                {
                    Logged?.Invoke("RX NAK — nothing to re-send (dropped)");
                }
                break;

            case RioControl.Restart:
                Logged?.Invoke("RX RESTART");
                break;

            case RioControl.Idle:
                break; // keep-alive noise

            default:
                Logged?.Invoke($"RX stray byte 0x{b:X2}");
                break;
        }
    }
}
