using System.Diagnostics;
using System.IO.Ports;
using VPlasma.App;
using VPlasma.Core.Device;
using VRio.Core.Device;
using VRio.Core.Input;

namespace VRio.App;

/// <summary>
/// vRIO main window: the interactive cockpit panel on the left (the same
/// functional map RIOJoy's profile editor shows) and a control strip on the
/// right — COM ports, device settings, and a live wire log. The panel's
/// encoder strip also hosts the built-in plasma glass (the vPLASMA display
/// emulator) on its own COM port. At startup the usual ports
/// (<see cref="PreferredPort"/>, <see cref="PreferredPlasmaPort"/>) are
/// opened automatically when available; otherwise open them by hand. Point
/// RIOJoy at the other end of the null-modem pair, and every click here
/// arrives at RIOJoy exactly as if the physical cockpit sent it; point the
/// game's COM2 passthrough at the plasma pair and the glass lights up.
/// Both devices also serve the DOSBox-X fork's named-pipe transport
/// (<c>\\.\pipe\vrio</c>, <c>\\.\pipe\vplasma</c>) for the whole app
/// lifetime — the com0com-free path.
/// </summary>
internal sealed class MainForm : Form
{
    /// <summary>
    /// vRIO's usual port: the device end of the COM1⇄COM11 com0com pair.
    /// Auto-opened at startup when present and free; the picker still allows
    /// any other port.
    /// </summary>
    private const string PreferredPort = "COM11";

    /// <summary>
    /// The built-in plasma's usual port: the device end of the COM2⇄COM12
    /// com0com pair (the game's COM2 passthrough opens the other end).
    /// </summary>
    private const string PreferredPlasmaPort = "COM12";

    private readonly VRioDevice _device = new();
    private readonly VRioSerialService _service;
    private readonly VRioPipeService _pipeService;
    private readonly VPlasmaDevice _plasmaDevice = new();
    private readonly VPlasmaSerialService _plasmaService;
    private readonly VPlasmaPipeService _plasmaPipeService;
    private readonly PanelCanvas _canvas = new();
    private readonly PlasmaCanvas _plasmaCanvas = new(PanelCanvas.PlasmaDotPitch)
    {
        Location = PanelCanvas.PlasmaSlot,
    };
    private int _selfTestPage;
    private readonly InputRouter _router;
    private readonly XInputGamepad _gamepad = new();
    private readonly KeyboardLampMirror _lampMirror;
    private readonly RawKeyboardSource _rawKeyboard = new();
    private string? _activeInputId; // interface path of the keyboard being captured; null = WinForms focus path
    private readonly string _bindingsPath = Path.Combine(
        Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "vRIO", "bindings.txt");

    // Port rows: one line per built-in device — a name label whose colour is
    // the port status (green = open, gray = closed), the port picker, and an
    // Open/Close button. The one Rescan button refreshes both pickers.
    private readonly Label _rioLabel = new()
    {
        Text = "vRIO",
        Location = new Point(12, 15),
        AutoSize = true,
        ForeColor = Color.Gray,
    };
    private readonly ComboBox _portBox = new()
    {
        Location = new Point(80, 12),
        Width = 128,
        DropDownStyle = ComboBoxStyle.DropDownList,
    };
    private readonly Button _rescan = new() { Text = "Rescan", Location = new Point(214, 11), Width = 52 };
    private readonly Button _openClose = new() { Text = "Open", Location = new Point(272, 11), Width = 46 };
    private readonly Label _plasmaLabel = new()
    {
        Text = "vPLASMA",
        Location = new Point(12, 45),
        AutoSize = true,
        ForeColor = Color.Gray,
    };
    private readonly ComboBox _plasmaPortBox = new()
    {
        Location = new Point(80, 42),
        Width = 128,
        DropDownStyle = ComboBoxStyle.DropDownList,
    };
    private readonly Button _plasmaOpenClose = new() { Text = "Open", Location = new Point(272, 41), Width = 46 };

    private readonly CheckBox _spring = new() { Text = "Stick springs back to center", Location = new Point(10, 24), AutoSize = true, Checked = true };
    private readonly Button _centerAxes = new() { Text = "Center all axes", Location = new Point(10, 52), Width = 140, Height = 26 };
    private readonly Button _lampsOff = new() { Text = "All lamps off", Location = new Point(156, 52), Width = 140, Height = 26 };

    private readonly CheckBox _kbInput = new() { Text = "Keyboard", Location = new Point(10, 22), AutoSize = true, Checked = true };
    private readonly CheckBox _padInput = new() { Text = "Gamepad", Location = new Point(100, 22), AutoSize = true, Checked = true };
    private readonly CheckBox _invertY = new() { Text = "Invert Y", Location = new Point(190, 22), AutoSize = true };
    private readonly Label _padStatus = new()
    {
        Text = "No controller detected.",
        Location = new Point(10, 46),
        AutoSize = true,
        ForeColor = Color.Gray,
    };
    private readonly Button _reloadBindings = new() { Text = "Reload bindings", Location = new Point(10, 68), Width = 140, Height = 26 };
    private readonly Button _editBindings = new() { Text = "Edit bindings…", Location = new Point(156, 68), Width = 140, Height = 26 };
    private readonly CheckBox _kbLights = new()
    {
        Text = "Mirror lamps on RGB keyboard (Dynamic Lighting)",
        Location = new Point(10, 100),
        AutoSize = true,
    };
    private readonly ComboBox _kbLightsTarget = new()
    {
        Location = new Point(10, 122),
        Width = 286,
        DropDownStyle = ComboBoxStyle.DropDownList,
        Enabled = false, // populated while the mirror is on
    };
    private readonly Label _kbInputLabel = new()
    {
        Text = "Capture keyboard (no window focus needed):",
        Location = new Point(10, 144),
        AutoSize = true,
    };
    private readonly ComboBox _kbInputTarget = new()
    {
        Location = new Point(10, 162),
        Width = 286,
        DropDownStyle = ComboBoxStyle.DropDownList,
    };

    /// <summary>A keyboard choice in the lamp-mirror picker (null id = all).</summary>
    private sealed record KbChoice(string? Id, string Name)
    {
        public override string ToString() => Name;
    }

    private readonly Label _counters = new()
    {
        Location = new Point(12, 372),
        AutoSize = true,
        Font = new Font("Consolas", 8f),
    };

    private readonly Label _help = new()
    {
        Location = new Point(12, 428),
        MaximumSize = new Size(306, 0),
        AutoSize = true,
        ForeColor = Color.Gray,
        Text = "Left-click a cell: momentary press.  Right-click: latch it down.  " +
               "Drag the X/Y box and the Z / L / R gauges to move the axes.  " +
               "Double-click the plasma glass to cycle its self-test pages; right-click it to reset.",
    };

    private readonly TextBox _logBox = new()
    {
        Location = new Point(12, 498),
        Multiline = true,
        ReadOnly = true,
        ScrollBars = ScrollBars.Both, // long wire lines don't wrap — scroll to read
        BackColor = Color.FromArgb(24, 24, 24),
        ForeColor = Color.Gainsboro,
        Font = new Font("Consolas", 8f),
        Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left,
        WordWrap = false,
    };
    private readonly Button _clearLog = new()
    {
        Text = "Clear log",
        Width = 80,
        Anchor = AnchorStyles.Bottom | AnchorStyles.Left,
    };

    private readonly System.Windows.Forms.Timer _uiTimer = new() { Interval = 500 };
    private readonly System.Windows.Forms.Timer _inputTimer = new() { Interval = 16 };
    private readonly Stopwatch _clock = Stopwatch.StartNew();
    private double _lastInputTick;

    public MainForm()
    {
        // ProductVersion carries the git stamp (see StampGitVersion in the csproj).
        Text = $"vRIO v{Application.ProductVersion} — Virtual RIO cockpit device";
        // Title-bar/taskbar icon from the exe's embedded ApplicationIcon (vwe.ico).
        Icon = Icon.ExtractAssociatedIcon(Application.ExecutablePath);
        // Fit the window to its content: the cockpit canvas plus the 330px
        // control strip, with just enough height for the strip's log area.
        ClientSize = new Size(_canvas.Width + 332, Math.Max(_canvas.Height, 640));
        MinimumSize = new Size(1000, 620);
        StartPosition = FormStartPosition.CenterScreen;
        KeyPreview = true; // form-level key routing for the input bindings

        _service = new VRioSerialService(_device);
        _plasmaService = new VPlasmaSerialService(_plasmaDevice);
        // The named-pipe endpoints for DOSBox-X's namedpipe serial backend.
        // Unlike a COM port, a listening pipe costs nothing and conflicts with
        // nothing, so both servers run for the whole app lifetime.
        _pipeService = new VRioPipeService(_device);
        _plasmaPipeService = new VPlasmaPipeService(_plasmaDevice);
        _router = new InputRouter(_device);
        _lampMirror = new KeyboardLampMirror(_device);

        // Panel canvas, scrolled if the window is smaller than the grid. The
        // plasma glass rides along as a child in the encoder strip's left slot.
        var scroller = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Color.FromArgb(28, 28, 28) };
        _canvas.Controls.Add(_plasmaCanvas);
        scroller.Controls.Add(_canvas);

        Controls.Add(scroller);
        Controls.Add(BuildControlStrip());

        // Canvas ↔ device wiring.
        _canvas.LampProvider = _device.GetLamp;
        _canvas.AxisProvider = _device.GetAxis;
        _canvas.WireAxisProvider = _device.GetWireAxis;
        _canvas.AddressPressed += _device.PressAddress;
        _canvas.AddressReleased += _device.ReleaseAddress;
        _canvas.AxisMoved += (axis, value) => _device.SetAxis(axis, value);

        // Router-driven presses light up like clicks (router runs on the UI thread).
        _router.AddressHeldChanged += _canvas.SetExternalHeld;

        // Device / service events arrive on worker threads; marshal to the UI.
        _device.LampChanged += (_, _) => RunOnUi(_canvas.Invalidate);
        _device.AxesChanged += () => RunOnUi(_canvas.Invalidate);
        // A host reset re-zeroes the axes behind the router's back.
        _device.ResetReceived += _ => RunOnUi(_router.ResetAxisState);
        _device.Logged += line => RunOnUi(() => PrependLog(line));
        _service.Logged += line => RunOnUi(() => PrependLog(line));
        _lampMirror.Logged += line => RunOnUi(() => PrependLog(line));
        _service.ConnectionChanged += open => RunOnUi(() => OnConnectionChanged(open));
        _service.HostHandshake += high => RunOnUi(() =>
            PrependLog(high ? "Host raised DTR (board-reset handshake)" : "Host dropped DTR"));
        _pipeService.Logged += line => RunOnUi(() => PrependLog($"pipe: {line}"));
        _pipeService.HostHandshake += high => RunOnUi(() =>
            PrependLog(high ? "Pipe host raised DTR (board-reset handshake)" : "Pipe host dropped DTR"));
        _pipeService.ClientChanged += connected => RunOnUi(() =>
        {
            // The RIO is a single-host device; two live transports means two
            // hosts fighting over one board.
            if (connected && _service.IsOpen)
                PrependLog($"Warning: a pipe client connected while {_service.PortName} is open — close one transport.");
        });
        _plasmaPipeService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA pipe: {line}"));

        // Built-in plasma glass: a pure listener on its own port, same gestures
        // as the standalone vPLASMA — double-click cycles the self-test pages,
        // right-click resets the display to its power-on state.
        _plasmaDevice.Updated += () => RunOnUi(() => _plasmaCanvas.UpdateFrom(_plasmaDevice));
        _plasmaService.Logged += line => RunOnUi(() => PrependLog($"vPLASMA: {line}"));
        _plasmaService.ConnectionChanged += open => RunOnUi(() => OnPlasmaConnectionChanged(open));
        _plasmaCanvas.DoubleClick += (_, _) => RunPlasmaSelfTest();
        _plasmaCanvas.MouseClick += (_, e) =>
        {
            if (e.Button == MouseButtons.Right)
                _plasmaDevice.Reset();
        };

        _rescan.Click += (_, _) => RefreshPorts();
        _openClose.Click += (_, _) => ToggleOpen();
        _plasmaOpenClose.Click += (_, _) => TogglePlasmaOpen();
        _spring.CheckedChanged += (_, _) => _canvas.StickSpringsBack = _spring.Checked;
        _centerAxes.Click += (_, _) =>
        {
            foreach (RioAxis axis in (RioAxis[])Enum.GetValues(typeof(RioAxis)))
                _device.SetAxis(axis, 0);
            _router.ResetAxisState();
        };
        _lampsOff.Click += (_, _) =>
        {
            _device.ClearLamps();
            _canvas.Invalidate();
        };
        _clearLog.Click += (_, _) => _logBox.Clear();

        _kbInput.CheckedChanged += (_, _) =>
        {
            if (!_kbInput.Checked)
                _router.ReleaseAllKeys();
        };
        _invertY.CheckedChanged += (_, _) =>
        {
            _device.InvertJoystickY = _invertY.Checked;
            _canvas.Invalidate(); // the wire readout flips even though the axes didn't move
        };
        _device.InvertJoystickY = _invertY.Checked; // the field initializer raises no event
        _kbLights.CheckedChanged += (_, _) =>
        {
            _lampMirror.Enabled = _kbLights.Checked;
            _kbLightsTarget.Enabled = _kbLights.Checked;
        };
        _kbLightsTarget.SelectedIndexChanged += (_, _) =>
            _lampMirror.SetTarget((_kbLightsTarget.SelectedItem as KbChoice)?.Id);
        _lampMirror.KeyboardsChanged += list => RunOnUi(() => RebuildKeyboardPicker(list));
        _kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
        _kbLightsTarget.SelectedIndex = 0;

        // Raw-input capture: a chosen keyboard drives the panel even while the
        // sim has focus. Down-edges only fire when keyboard input is enabled;
        // up-edges always land so a release is never stranded by a mid-hold flip.
        _rawKeyboard.KeyDown += name => RunOnUi(() => { if (_kbInput.Checked) _router.KeyDown(name); });
        _rawKeyboard.KeyUp += name => RunOnUi(() => _router.KeyUp(name));
        _rawKeyboard.Logged += line => RunOnUi(() => PrependLog(line));
        _rawKeyboard.KeyboardsChanged += list => RunOnUi(() => RebuildInputKeyboardPicker(list));
        _kbInputTarget.SelectedIndexChanged += (_, _) => ApplyInputSource();
        _kbInputTarget.DropDown += (_, _) => _rawKeyboard.RefreshKeyboards();
        _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
        _kbInputTarget.SelectedIndex = 0;
        _reloadBindings.Click += (_, _) => LoadBindings();
        _editBindings.Click += (_, _) => OpenBindingsFile();

        _uiTimer.Tick += (_, _) => UpdateStatus();
        _uiTimer.Start();

        _inputTimer.Tick += (_, _) => InputTick();
        _inputTimer.Start();

        FormClosed += (_, _) =>
        {
            _uiTimer.Dispose();
            _inputTimer.Dispose();
            _lampMirror.Dispose();
            _rawKeyboard.Dispose();
            _service.Dispose();
            _plasmaService.Dispose();
            _pipeService.Dispose();
            _plasmaPipeService.Dispose();
        };

        RefreshPorts();
        UpdateStatus();
        PrependLog("vRIO ready. Open a COM port, then point RIOJoy at the other end of the pair.");
        LoadBindings();
        _plasmaCanvas.UpdateFrom(_plasmaDevice); // paint the power-on frame
        AutoOpenPreferredPorts();
        _pipeService.Start();
        _plasmaPipeService.Start();
    }

    private Panel BuildControlStrip()
    {
        var panel = new Panel
        {
            Dock = DockStyle.Right,
            Width = 330,
            // Height must be set before the anchored children are added:
            // Anchor offsets are captured against the parent's current size,
            // and the 100px default would pin the log box's bottom far below
            // the panel, making it grow past the window instead of scrolling.
            Height = ClientSize.Height,
            Padding = new Padding(8),
            BorderStyle = BorderStyle.FixedSingle,
        };

        panel.Controls.Add(_rioLabel);
        panel.Controls.Add(_portBox);
        panel.Controls.Add(_rescan);
        panel.Controls.Add(_openClose);
        panel.Controls.Add(_plasmaLabel);
        panel.Controls.Add(_plasmaPortBox);
        panel.Controls.Add(_plasmaOpenClose);

        var device = new GroupBox { Text = "Device", Location = new Point(12, 68), Size = new Size(306, 88) };
        device.Controls.AddRange(new Control[] { _spring, _centerAxes, _lampsOff });
        panel.Controls.Add(device);

        var input = new GroupBox { Text = "Input", Location = new Point(12, 162), Size = new Size(306, 202) };
        input.Controls.AddRange(new Control[] { _kbInput, _padInput, _invertY, _padStatus, _reloadBindings, _editBindings, _kbLights, _kbLightsTarget, _kbInputLabel, _kbInputTarget });
        panel.Controls.Add(input);

        panel.Controls.Add(_counters);
        panel.Controls.Add(_help);
        panel.Controls.Add(new Label { Text = "Wire log:", Location = new Point(12, 480), AutoSize = true });

        _logBox.Size = new Size(306, ClientSize.Height - _logBox.Top - 44);
        panel.Controls.Add(_logBox);

        _clearLog.Location = new Point(12, ClientSize.Height - 36);
        panel.Controls.Add(_clearLog);

        return panel;
    }

    // ---- Port handling -----------------------------------------------------

    private void RefreshPorts()
    {
        string[] names = SerialPort.GetPortNames().Distinct()
            .OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToArray();
        // An open service's picker is disabled and shows the served port;
        // leave it alone so the display can't drift from reality.
        if (!_service.IsOpen)
            RefreshPicker(_portBox, names);
        if (!_plasmaService.IsOpen)
            RefreshPicker(_plasmaPortBox, names);
    }

    private static void RefreshPicker(ComboBox box, string[] portNames)
    {
        string? current = box.SelectedItem?.ToString();
        box.Items.Clear();
        foreach (string name in portNames)
            box.Items.Add(name);

        if (box.Items.Count == 0)
            return;
        int idx = current is null ? -1 : box.Items.IndexOf(current);
        box.SelectedIndex = idx >= 0 ? idx : 0;
    }

    /// <summary>
    /// Startup convenience: if a device's usual port exists, select it and try
    /// to open it. Failures (port missing, or busy because another vRIO/app
    /// holds it) just log — no modal box at launch — and leave the manual
    /// pickers in charge.
    /// </summary>
    private void AutoOpenPreferredPorts()
    {
        AutoOpen(_portBox, PreferredPort, _service.Open);
        AutoOpen(_plasmaPortBox, PreferredPlasmaPort, _plasmaService.Open);
    }

    private void AutoOpen(ComboBox box, string port, Action<string> open)
    {
        int idx = box.Items.IndexOf(port);
        if (idx < 0)
        {
            PrependLog($"{port} not present — pick a port and open it manually.");
            return;
        }

        box.SelectedIndex = idx;
        try
        {
            open(port);
        }
        catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or ArgumentException)
        {
            PrependLog($"{port} is present but could not be opened ({ex.Message.TrimEnd('.')}) — open it manually once it frees up.");
        }
    }

    private void ToggleOpen()
    {
        if (_service.IsOpen)
            _service.Close();
        else
            OpenFromPicker(_portBox, _service.Open);
    }

    private void TogglePlasmaOpen()
    {
        if (_plasmaService.IsOpen)
            _plasmaService.Close();
        else
            OpenFromPicker(_plasmaPortBox, _plasmaService.Open);
    }

    private void OpenFromPicker(ComboBox box, Action<string> open)
    {
        if (box.SelectedItem?.ToString() is not { } port)
        {
            MessageBox.Show(this, "No COM port selected. On a single PC, install a com0com virtual " +
                "null-modem pair and open one end here.", "vRIO", MessageBoxButtons.OK, MessageBoxIcon.Information);
            return;
        }

        try
        {
            open(port);
        }
        catch (Exception ex)
        {
            MessageBox.Show(this, $"Could not open {port}:\n{ex.Message}", "vRIO",
                MessageBoxButtons.OK, MessageBoxIcon.Warning);
        }
    }

    private void OnConnectionChanged(bool open)
    {
        _openClose.Text = open ? "Close" : "Open";
        _portBox.Enabled = !open;
        _rioLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
        UpdateStatus();
    }

    private void OnPlasmaConnectionChanged(bool open)
    {
        _plasmaOpenClose.Text = open ? "Close" : "Open";
        _plasmaPortBox.Enabled = !open;
        _plasmaLabel.ForeColor = open ? Color.ForestGreen : Color.Gray;
    }

    /// <summary>
    /// Feed the next canned self-test page through the plasma's wire parser —
    /// the same end-to-end exercise the standalone vPLASMA runs on double-click.
    /// </summary>
    private void RunPlasmaSelfTest()
    {
        byte[] bytes = PlasmaSelfTest.BuildPage(_selfTestPage);
        _plasmaDevice.OnReceived(bytes, bytes.Length);
        _selfTestPage = (_selfTestPage + 1) % PlasmaSelfTest.PageCount;
    }

    // ---- Keyboard / gamepad input -------------------------------------------

    /// <summary>
    /// Keys route to the panel unless the user is in a control that needs
    /// them (port list, log box scrolling).
    /// </summary>
    private bool KeyboardRoutingActive =>
        _kbInput.Checked &&
        !_portBox.ContainsFocus && !_logBox.ContainsFocus;

    protected override void OnHandleCreated(EventArgs e)
    {
        base.OnHandleCreated(e);
        _rawKeyboard.Attach(Handle);
        _rawKeyboard.RefreshKeyboards(); // seed the capture picker
    }

    protected override void WndProc(ref Message m)
    {
        // Raw Input arrives outside the normal key-message path; feed it to the
        // capture source, then let DefWindowProc run its WM_INPUT cleanup.
        const int WM_INPUT = 0x00FF, WM_INPUT_DEVICE_CHANGE = 0x00FE;
        switch (m.Msg)
        {
            case WM_INPUT:
                _rawKeyboard.ProcessInput(m.LParam);
                break;
            case WM_INPUT_DEVICE_CHANGE:
                _rawKeyboard.HandleDeviceChange();
                break;
        }
        base.WndProc(ref m);
    }

    protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
    {
        // Intercept WM_KEYDOWN before dialog-key processing, or arrows/space
        // would move focus and click buttons instead of reaching the panel.
        const int WM_KEYDOWN = 0x0100, WM_SYSKEYDOWN = 0x0104;
        if ((msg.Msg is WM_KEYDOWN or WM_SYSKEYDOWN)
            && (keyData & (Keys.Control | Keys.Alt)) == 0
            && KeyboardRoutingActive)
        {
            string name = (keyData & Keys.KeyCode).ToString();
            if (_router.HasKeyBinding(name))
            {
                // While a specific keyboard is captured, that source owns
                // routing; still swallow the key so it can't click a button.
                if (!_rawKeyboard.IsCapturing)
                    _router.KeyDown(name);
                return true;
            }
        }
        return base.ProcessCmdKey(ref msg, keyData);
    }

    protected override void OnKeyUp(KeyEventArgs e)
    {
        // Unconditional in focus mode: the router ignores keys it never saw go
        // down, and a release must land even if the checkbox flipped mid-hold.
        // In capture mode the raw source owns edges, so don't double-release.
        if (!_rawKeyboard.IsCapturing)
            _router.KeyUp(e.KeyCode.ToString());
        base.OnKeyUp(e);
    }

    protected override void OnDeactivate(EventArgs e)
    {
        // Focus mode loses key-up events once unfocused, so release held keys.
        // Capture mode keeps receiving them in the background — leave holds be.
        if (!_rawKeyboard.IsCapturing)
            _router.ReleaseAllKeys();
        base.OnDeactivate(e);
    }

    /// <summary>Apply the capture-picker selection: swap the keyboard input source.</summary>
    private void ApplyInputSource()
    {
        string? id = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
        if (id == _activeInputId)
            return;
        _activeInputId = id;
        _router.ReleaseAllKeys(); // don't strand holds across a source swap
        _rawKeyboard.SetTarget(id);
        PrependLog(id is null
            ? "Keyboard input: all keyboards, only while vRIO has focus"
            : $"Keyboard input: capturing \"{(_kbInputTarget.SelectedItem as KbChoice)?.Name}\" in the background");
    }

    /// <summary>Refresh the capture picker, keeping the pick if the device survived.</summary>
    private void RebuildInputKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
    {
        string? selected = (_kbInputTarget.SelectedItem as KbChoice)?.Id;
        _kbInputTarget.BeginUpdate();
        _kbInputTarget.Items.Clear();
        _kbInputTarget.Items.Add(new KbChoice(null, "All keyboards (focus only)"));
        int select = 0;
        foreach ((string id, string name) in keyboards)
        {
            int idx = _kbInputTarget.Items.Add(new KbChoice(id, name));
            if (id == selected)
                select = idx;
        }
        // A vanished capture device falls back to "All keyboards"; the
        // selection-changed handler (ApplyInputSource) unregisters accordingly.
        _kbInputTarget.SelectedIndex = select;
        _kbInputTarget.EndUpdate();
    }

    private void InputTick()
    {
        double now = _clock.Elapsed.TotalSeconds;
        double dt = Math.Min(0.25, now - _lastInputTick); // cap catch-up after a stall
        _lastInputTick = now;

        if (_padInput.Checked && _gamepad.TryRead(out var pad))
            _router.SetPadState(pad);
        else
            _router.SetPadState(default); // releases whatever the pad held

        _router.Tick(dt);

        string status = _padInput.Checked
            ? _gamepad.Connected ? $"Controller #{_gamepad.UserIndex + 1} connected." : "No controller detected."
            : "Gamepad input off.";
        if (_padStatus.Text != status)
        {
            _padStatus.Text = status;
            _padStatus.ForeColor = _gamepad.Connected && _padInput.Checked ? Color.ForestGreen : Color.Gray;
        }
    }

    private void LoadBindings()
    {
        try
        {
            string text;
            if (File.Exists(_bindingsPath))
            {
                text = File.ReadAllText(_bindingsPath);
            }
            else
            {
                // First run: materialize the commented default file so
                // "Edit bindings…" has something self-documenting to open.
                Directory.CreateDirectory(Path.GetDirectoryName(_bindingsPath)!);
                File.WriteAllText(_bindingsPath, BindingProfileFormat.DefaultText);
                text = BindingProfileFormat.DefaultText;
            }

            var profile = BindingProfileFormat.Parse(text, out var errors);
            _router.Profile = profile;
            _lampMirror.SetProfile(profile);
            foreach (string error in errors)
                PrependLog($"Bindings: {error}");
            PrependLog($"Bindings loaded: {profile.Count} ({_bindingsPath})");
        }
        catch (Exception ex)
        {
            PrependLog($"Bindings load failed: {ex.Message}");
        }
    }

    private void OpenBindingsFile()
    {
        try
        {
            if (!File.Exists(_bindingsPath))
                LoadBindings(); // writes the default file
            Process.Start(new ProcessStartInfo(_bindingsPath) { UseShellExecute = true });
        }
        catch (Exception ex)
        {
            MessageBox.Show(this, $"Could not open {_bindingsPath}:\n{ex.Message}", "vRIO",
                MessageBoxButtons.OK, MessageBoxIcon.Warning);
        }
    }

    /// <summary>Refresh the lamp-mirror keyboard picker, keeping the pick if it survived.</summary>
    private void RebuildKeyboardPicker(IReadOnlyList<(string Id, string Name)> keyboards)
    {
        string? selected = (_kbLightsTarget.SelectedItem as KbChoice)?.Id;
        _kbLightsTarget.BeginUpdate();
        _kbLightsTarget.Items.Clear();
        _kbLightsTarget.Items.Add(new KbChoice(null, "All keyboards"));
        int select = 0;
        foreach ((string id, string name) in keyboards)
        {
            int idx = _kbLightsTarget.Items.Add(new KbChoice(id, name));
            if (id == selected)
                select = idx;
        }
        // Falls back to "All keyboards" if the picked device vanished (the
        // selection-changed handler re-targets the mirror accordingly).
        _kbLightsTarget.SelectedIndex = select;
        _kbLightsTarget.EndUpdate();
    }

    // ---- Status / log ------------------------------------------------------

    private void UpdateStatus()
    {
        long polls = _device.AnalogRequests;
        long dropped = _device.AnalogDropped;
        long bad = _device.BadChecksums;
        bool wedged = _device.AnalogWedged;
        _counters.Text = $"Analog polls served:  {polls}\n" +
                         $"Analog polls dropped: {dropped}\n" +
                         $"Bad checksums:        {bad}";

        _canvas.StatusText = _service.IsOpen
            ? $"vRIO {_device.VersionMajor}.{_device.VersionMinor} on {_service.PortName}\n" +
              (wedged
                  ? "** ANALOG WEDGED (v4.2 bug) — awaiting host reset **"
                  : $"{polls} analog polls" + (polls > 0 ? "  (host is alive)" : "  (no host traffic yet)"))
            : "PORT CLOSED\nOpen a COM port to go live.";
    }

    private void PrependLog(string line)
    {
        // Newest entry on top; older lines flow downward and eventually fall
        // off the bounded end, so the visible top is always current.
        string text = $"[{_clock.Elapsed.TotalSeconds,8:F2}s] {line}{Environment.NewLine}" + _logBox.Text;

        // Bound the log so an all-day session doesn't grow without limit;
        // cut at a line boundary so the tail isn't half an entry.
        if (text.Length > 120_000)
        {
            int cut = text.LastIndexOf(Environment.NewLine, 60_000, StringComparison.Ordinal);
            text = text.Substring(0, cut < 0 ? 60_000 : cut + Environment.NewLine.Length);
        }

        _logBox.Text = text;
        _logBox.SelectionStart = 0;
        _logBox.ScrollToCaret();
    }

    private void RunOnUi(Action action)
    {
        if (IsDisposed)
            return;
        if (InvokeRequired)
        {
            try { BeginInvoke(action); }
            catch (ObjectDisposedException) { }
            catch (InvalidOperationException) { } // handle not created / closing
            return;
        }
        action();
    }
}
