// =============================================================================
// TeslaLauncher — Userspace Agent
// =============================================================================
// Runs as a WinForms tray application in the logged-in user's desktop session.
// On first boot (unconfigured machine), displays a splash with the SecureConfig
// Request ID and Passphrase so the operator can read them to the console.
// On subsequent boots, listens on a Named Pipe for commands from
// TeslaLauncherService and manages simulation applications.
// =============================================================================

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Drawing;
using System.IO;
using System.IO.IsolatedStorage;
using System.IO.Pipes;
using System.Linq;
using System.Net.NetworkInformation;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using Tesla.Launcher.Shared;

namespace Tesla.Launcher.Agent
{
    public class AgentApplication : Form
    {
        // ── Configuration ─────────────────────────────────────────────────────
        private const string PIPE_NAME   = "TeslaLauncherIPC";
        private const string CONFIG_FILE = @"C:\ProgramData\TeslaLauncher\LaunchApps.xml";
        private const string GAMES_DIR   = @"C:\Games";
        // ─────────────────────────────────────────────────────────────────────

        private NotifyIcon       _trayIcon;
        private ContextMenuStrip _trayMenu;

        private List<LaunchData> _launchApps = new();
        private readonly Dictionary<string, List<Process>> _runningProcesses = new();
        private readonly object _processLock = new();
        private readonly List<Thread> _watcherThreads = new();
        private bool _stopping = false;

        private readonly Dictionary<string, OutOfBandProgress> _installProgress = new();
        private readonly object _installLock = new();

        private Thread _pipeThread;
        private CancellationTokenSource _cts = new();

        // ── Entry point ───────────────────────────────────────────────────────

        [STAThread]
        public static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);

            // First-boot check: show the configuring splash (with the SecureConfig
            // Request ID + Passphrase) when the machine is unconfigured OR when
            // SecureConfig is actively running.
            //
            // The second condition is essential: during SecureConfig the Service
            // assigns a TEMPORARY static IP to the Ethernet adapter so it can
            // broadcast. That makes IsMachineConfigured() report true, so the DHCP
            // check ALONE would skip the splash and the codes would never reach the
            // screen — they'd only be in the log / on the COM2 plasma display. The
            // Service writes configuring.json *before* assigning that temp IP, so its
            // presence is the authoritative "SecureConfig in progress" signal.
            if (!IsMachineConfigured() || File.Exists(ConfiguringFilePath()))
            {
                ShowConfiguringWait();   // blocks until config completes or form closed

                // If still not configured (config failed / user closed splash), exit.
                if (!IsMachineConfigured())
                    return;
            }

            Application.Run(new AgentApplication());
        }

        // ── Secure Configuration ──────────────────────────────────────────────

        /// <summary>
        /// Returns true when the cockpit has already been configured —
        /// detected by the Ethernet adapter having a static IP assignment.
        /// SecureConfiguration writes the permanent address via netsh with
        /// no DHCP, so a static adapter means configuration is complete.
        /// A DHCP adapter (or no adapter) means we are on a fresh machine
        /// and need to run the SecureConfiguration protocol.
        /// </summary>
        private static bool IsMachineConfigured()
        {
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
                if (nic.OperationalStatus    != OperationalStatus.Up)          continue;
                if (IsVirtualAdapter(nic))                                      continue;

                try
                {
                    var ipv4 = nic.GetIPProperties().GetIPv4Properties();
                    // IsDhcpEnabled == false  →  static IP  →  already configured
                    if (!ipv4.IsDhcpEnabled)
                        return true;
                }
                catch (NetworkInformationException)
                {
                    // GetIPv4Properties() throws if IPv4 is not configured at all;
                    // treat that the same as DHCP (i.e. not yet configured).
                }
            }
            return false; // all Ethernet adapters are DHCP or absent → needs configuration
        }

        /// <summary>Shared file the Service writes (before assigning the temp IP) while
        /// SecureConfig is in progress; holds the RequestId/Passphrase for the splash.</summary>
        private static string ConfiguringFilePath() => Path.Combine(
            Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
            "TeslaLauncher", "configuring.json");

        private static bool IsVirtualAdapter(NetworkInterface nic)
        {
            var desc = nic.Description ?? "";
            var name = nic.Name        ?? "";
            string[] markers = {
                "Virtual", "Hyper-V", "VMware", "VirtualBox",
                "Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
                "Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
            };
            foreach (var m in markers)
                if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
                    name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
                    return true;
            var mac = nic.GetPhysicalAddress().GetAddressBytes();
            if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true; // locally-administered MAC
            return false;
        }

        private static void ShowConfiguringWait()
        {
            // The Service handles SecureConfig in Session 0. The Agent shows
            // a splash with the RequestId and Passphrase for the operator.
            using var form = new Form
            {
                Text            = "Tesla Cockpit Configuration",
                FormBorderStyle = FormBorderStyle.FixedDialog,
                StartPosition   = FormStartPosition.CenterScreen,
                ClientSize      = new System.Drawing.Size(480, 260),
                MaximizeBox     = false,
                MinimizeBox     = false,
                TopMost         = true,
            };

            var lblStatus = new Label
            {
                Text      = "This cockpit is being configured.\r\n" +
                            "Configuration will complete automatically.",
                AutoSize  = false,
                Dock      = DockStyle.Top,
                Height    = 50,
                TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
                Font      = new System.Drawing.Font("Arial", 10f),
                Padding   = new Padding(12, 8, 12, 0),
            };
            form.Controls.Add(lblStatus);

            var lblRequestIdTitle = new Label
            {
                Text      = "Request ID:",
                AutoSize  = false,
                TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
                Font      = new System.Drawing.Font("Arial", 11f),
                Top       = 60,
                Left      = 0,
                Width     = 480,
                Height    = 25,
            };
            form.Controls.Add(lblRequestIdTitle);

            var lblRequestId = new Label
            {
                Text      = "waiting...",
                AutoSize  = false,
                TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
                Font      = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
                ForeColor = System.Drawing.Color.DarkBlue,
                Top       = 85,
                Left      = 0,
                Width     = 480,
                Height    = 45,
            };
            form.Controls.Add(lblRequestId);

            var lblPassphraseTitle = new Label
            {
                Text      = "Passphrase:",
                AutoSize  = false,
                TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
                Font      = new System.Drawing.Font("Arial", 11f),
                Top       = 140,
                Left      = 0,
                Width     = 480,
                Height    = 25,
            };
            form.Controls.Add(lblPassphraseTitle);

            var lblPassphrase = new Label
            {
                Text      = "waiting...",
                AutoSize  = false,
                TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
                Font      = new System.Drawing.Font("Consolas", 28f, System.Drawing.FontStyle.Bold),
                ForeColor = System.Drawing.Color.DarkRed,
                Top       = 165,
                Left      = 0,
                Width     = 480,
                Height    = 45,
            };
            form.Controls.Add(lblPassphrase);

            var lblHint = new Label
            {
                Text      = "Read the Passphrase to the console operator.",
                AutoSize  = false,
                TextAlign = System.Drawing.ContentAlignment.MiddleCenter,
                Font      = new System.Drawing.Font("Arial", 9f, System.Drawing.FontStyle.Italic),
                ForeColor = System.Drawing.Color.Gray,
                Top       = 220,
                Left      = 0,
                Width     = 480,
                Height    = 25,
            };
            form.Controls.Add(lblHint);

            string lastJson = null;
            bool sawFile = false;
            var cfgFile = ConfiguringFilePath();

            // Poll every 2 s — read codes from the Service's shared file.
            // The file lifecycle:
            //   1. Service deletes stale file on startup
            //   2. Service writes fresh file with new codes (before the temp IP)
            //   3. Agent reads and displays codes
            //   4. Service deletes file when config is complete → splash closes
            // If the Agent started before the file appeared, it keeps waiting; it
            // closes once the file we saw is gone, or the machine becomes configured
            // (covers the case where config finished before we managed to read it).
            var timer = new System.Windows.Forms.Timer { Interval = 2000 };
            timer.Tick += (s, e) =>
            {
                if (File.Exists(cfgFile))
                {
                    sawFile = true;
                    try
                    {
                        var json = File.ReadAllText(cfgFile);
                        if (json != lastJson)
                        {
                            lastJson = json;
                            var doc  = System.Text.Json.JsonDocument.Parse(json);
                            var root = doc.RootElement;
                            if (root.TryGetProperty("RequestId", out var rid))
                                lblRequestId.Text = rid.GetString();
                            if (root.TryGetProperty("Passphrase", out var pp))
                                lblPassphrase.Text = pp.GetString();
                        }
                    }
                    catch { /* file may be mid-write, retry next tick */ }
                }
                else if (sawFile || IsMachineConfigured())
                {
                    // File gone after we saw it, or the machine is now configured →
                    // SecureConfig is complete.
                    timer.Stop();
                    form.Close();
                }
            };
            timer.Start();

            Application.Run(form);
        }

        // ── Form / tray setup ─────────────────────────────────────────────────

        public AgentApplication()
        {
            ShowInTaskbar = false;
            WindowState   = FormWindowState.Minimized;
            Opacity       = 0;

            BuildTrayIcon();
            LoadLaunchApps();
            StartPipeServer();
        }

        private void BuildTrayIcon()
        {
            _trayMenu = new ContextMenuStrip();
            var version = typeof(AgentApplication).Assembly.GetName().Version;
            _trayMenu.Items.Add($"Tesla Launcher Agent  v{version}", null, null).Enabled = false;
            _trayMenu.Items.Add(new ToolStripSeparator());
            _trayMenu.Items.Add("Reload Config", null, (s, e) => LoadLaunchApps());
            _trayMenu.Items.Add("Kill All Apps", null, (s, e) => CmdKillAllApps());
            _trayMenu.Items.Add(new ToolStripSeparator());
            _trayMenu.Items.Add("Exit",          null, (s, e) => ExitAgent());

            _trayIcon = new NotifyIcon
            {
                Text             = "Tesla Launcher Agent",
                Icon             = SystemIcons.Application,
                ContextMenuStrip = _trayMenu,
                Visible          = true
            };
        }

        private void ExitAgent()
        {
            _stopping = true;
            _cts.Cancel();
            _trayIcon.Visible = false;
            Application.Exit();
        }

        // ── LaunchApps.xml ────────────────────────────────────────────────────

        private void LoadLaunchApps()
        {
            try
            {
                if (!File.Exists(CONFIG_FILE))
                {
                    _launchApps = new List<LaunchData>();
                    SetTrayStatus("Config not found");
                    return;
                }

                var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
                using var reader = File.OpenRead(CONFIG_FILE);
                _launchApps = (List<LaunchData>)xml.Deserialize(reader)
                              ?? new List<LaunchData>();

                SetTrayStatus($"{_launchApps.Count} apps configured");
            }
            catch (Exception ex)
            {
                SetTrayStatus($"Config error: {ex.Message}");
            }
        }

        private void SaveLaunchApps()
        {
            var xml = new System.Xml.Serialization.XmlSerializer(typeof(List<LaunchData>));
            Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_FILE)!);
            using var writer = File.CreateText(CONFIG_FILE);
            xml.Serialize(writer, _launchApps);
        }

        private void SetTrayStatus(string status)
        {
            if (_trayIcon != null) _trayIcon.Text = $"Tesla Launcher: {status}";
        }

        // ── Named Pipe Server ─────────────────────────────────────────────────

        private void StartPipeServer()
        {
            _pipeThread = new Thread(PipeServerLoop)
            {
                IsBackground = true,
                Name         = "TeslaLauncherPipeServer"
            };
            _pipeThread.Start();
        }

        private void PipeServerLoop()
        {
            var ct = _cts.Token;

            while (!ct.IsCancellationRequested)
            {
                try
                {
                    using var server = new NamedPipeServerStream(
                        PIPE_NAME,
                        PipeDirection.InOut,
                        NamedPipeServerStream.MaxAllowedServerInstances,
                        PipeTransmissionMode.Byte,
                        PipeOptions.Asynchronous);

                    server.WaitForConnectionAsync(ct).GetAwaiter().GetResult();
                    HandlePipeRequest(server);
                }
                catch (OperationCanceledException) { break; }
                catch (Exception)
                {
                    if (!ct.IsCancellationRequested) Thread.Sleep(500);
                }
            }
        }

        private void HandlePipeRequest(NamedPipeServerStream pipe)
        {
            IpcResponse response;
            try
            {
                var lenBuf = new byte[4];
                ReadExact(pipe, lenBuf);
                var reqBuf = new byte[BitConverter.ToInt32(lenBuf, 0)];
                ReadExact(pipe, reqBuf);

                var msg = JsonSerializer.Deserialize<IpcMessage>(
                    Encoding.UTF8.GetString(reqBuf));

                response = ExecuteCommand(msg);
            }
            catch (Exception ex)
            {
                response = new IpcResponse { Success = false, Message = ex.Message };
            }

            var resBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(response));
            pipe.Write(BitConverter.GetBytes(resBytes.Length), 0, 4);
            pipe.Write(resBytes, 0, resBytes.Length);
            pipe.Flush();
        }

        private static void ReadExact(Stream stream, byte[] buf)
        {
            var off = 0;
            while (off < buf.Length)
            {
                var n = stream.Read(buf, off, buf.Length - off);
                if (n == 0) throw new IOException("Pipe closed.");
                off += n;
            }
        }

        // ── Command Dispatcher ────────────────────────────────────────────────

        private IpcResponse ExecuteCommand(IpcMessage msg)
        {
            if (msg == null) return Fail("Null message received.");

            try
            {
                object result = msg.Command switch
                {
                    "PING"                   => (object)null,
                    "CLEARSTORE"             => CmdClearStore(),
                    "LAUNCHAPP"              => CmdLaunchApp(msg),
                    "KILLAPP"                => CmdKillApp(msg),
                    "KILLALLOFTYPE"          => CmdKillAllOfType(msg),
                    "KILLALLAPPS"            => CmdKillAllApps(),
                    "GETLAUNCHEDAPPS"        => CmdGetLaunchedApps(),
                    "GETLAUNCHABLEAPPS"      => CmdGetLaunchableApps(),
                    "GETINSTALLEDAPPS"       => CmdGetLaunchableApps(),
                    "REMOVEAPP"              => CmdRemoveApp(msg),
                    "INSTALLAPP"             => CmdInstallApp(msg),
                    "UNINSTALLAPP"           => CmdUninstallApp(msg),
                    "GET_VOLUMELEVEL"        => CmdGetVolumeLevel(),
                    "SET_VOLUMELEVEL"        => CmdSetVolumeLevel(msg),
                    "FULLUPDATE"             => CmdFullUpdate(),
                    "INITIATEINSTALLPRODUCT" => CmdInitiateInstallProduct(msg),
                    "GETOUTOFBANDPROGRESS"   => CmdGetOutOfBandProgress(msg),
                    "SHUTDOWN"               => CmdShutdown(msg),
                    _                        => throw new Exception($"Unknown command: {msg.Command}")
                };

                return Ok(result);
            }
            catch (Exception ex)
            {
                return Fail(ex.Message);
            }
        }

        // ── Command Implementations ───────────────────────────────────────────

        private object CmdClearStore()
        {
            var store = IsolatedStorageFile.GetMachineStoreForAssembly();
            foreach (var file in store.GetFileNames("*"))
                store.DeleteFile(file);
            LoadLaunchApps();
            return null;
        }

        private object CmdLaunchApp(IpcMessage msg)
        {
            var key = msg.LaunchKey
                ?? throw new Exception("LaunchApp requires a LaunchKey");

            var app = FindApp(key)
                ?? throw new Exception($"No app configured for key '{key}'");

            // A launch entry can be registered (pushed from the Console) before its game
            // files are installed. Fail cleanly with an actionable message rather than
            // surfacing a raw Win32 "file not found" from Process.Start.
            if (string.IsNullOrWhiteSpace(app.ExeFile) || !File.Exists(app.ExeFile))
                throw new Exception(
                    $"Cannot launch '{app.DisplayName ?? key}': executable not found at " +
                    $"'{app.ExeFile}'. The product may be registered but not yet installed on this pod.");

            var psi = new ProcessStartInfo
            {
                FileName         = app.ExeFile,
                Arguments        = app.Arguments ?? "",
                WorkingDirectory = app.WorkingDirectory ?? Path.GetDirectoryName(app.ExeFile),
                UseShellExecute  = false
            };

            var process = Process.Start(psi)
                ?? throw new Exception($"Process.Start returned null for '{app.ExeFile}'");

            lock (_processLock)
            {
                if (!_runningProcesses.ContainsKey(key))
                    _runningProcesses[key] = new List<Process>();
                _runningProcesses[key].Add(process);
            }

            if (app.AutoRestart) StartWatcher(app, process);

            SetTrayStatus($"Running: {app.DisplayName ?? key}");
            return new LaunchedAppData { LaunchKey = key, ProcessId = process.Id };
        }

        private object CmdKillApp(IpcMessage msg)
        {
            var key = msg.LaunchKey;
            int? pid = null;

            if (msg.PayloadJson != null)
            {
                var parms = JsonSerializer.Deserialize<object[]>(msg.PayloadJson);
                if (parms?.Length > 1 && parms[1] is JsonElement je)
                    pid = je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int?)null;
            }

            lock (_processLock)
            {
                if (!_runningProcesses.TryGetValue(key ?? "", out var procs)) return null;

                var toKill = pid.HasValue
                    ? procs.Where(p => p.Id == pid.Value).ToList()
                    : procs.ToList();

                foreach (var p in toKill)
                {
                    try { if (!p.HasExited) p.Kill(); } catch { }
                    procs.Remove(p);
                }

                if (procs.Count == 0) _runningProcesses.Remove(key ?? "");
            }

            return null;
        }

        private object CmdKillAllOfType(IpcMessage msg)
        {
            var key = msg.LaunchKey ?? throw new Exception("KillAllOfType requires LaunchKey");

            lock (_processLock)
            {
                if (_runningProcesses.TryGetValue(key, out var procs))
                {
                    foreach (var p in procs)
                        try { if (!p.HasExited) p.Kill(); } catch { }
                    _runningProcesses.Remove(key);
                }
            }

            return null;
        }

        private object CmdKillAllApps()
        {
            lock (_processLock)
            {
                foreach (var kvp in _runningProcesses)
                    foreach (var p in kvp.Value)
                        try { if (!p.HasExited) p.Kill(); } catch { }
                _runningProcesses.Clear();
            }

            SetTrayStatus("All apps stopped");
            return null;
        }

        private object CmdGetLaunchedApps()
        {
            var result = new List<LaunchedAppData>();

            lock (_processLock)
            {
                foreach (var kvp in _runningProcesses)
                {
                    kvp.Value.RemoveAll(p => p.HasExited);

                    foreach (var p in kvp.Value)
                        result.Add(new LaunchedAppData
                        {
                            LaunchKey = kvp.Key,
                            ProcessId = p.Id
                        });
                }

                foreach (var key in _runningProcesses.Keys
                    .Where(k => _runningProcesses[k].Count == 0).ToList())
                    _runningProcesses.Remove(key);
            }

            return result.ToArray();
        }

        private object CmdGetLaunchableApps() => _launchApps.ToArray();

        private object CmdRemoveApp(IpcMessage msg)
        {
            var key = msg.LaunchKey ?? throw new Exception("RemoveApp requires LaunchKey");
            _launchApps.RemoveAll(a => a.LaunchKey == key);
            SaveLaunchApps();
            return null;
        }

        private object CmdInstallApp(IpcMessage msg)
        {
            if (msg.PayloadJson == null)
                throw new Exception("InstallApp requires PayloadJson");

            var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
            if (parms == null || parms.Length == 0)
                throw new Exception("InstallApp: no parameters");

            var appData = JsonSerializer.Deserialize<LaunchData>(parms[0].GetRawText())
                ?? throw new Exception("InstallApp: could not deserialize LaunchData");

            var existing = _launchApps.FindIndex(a => a.LaunchKey == appData.LaunchKey);
            if (existing >= 0) _launchApps[existing] = appData;
            else               _launchApps.Add(appData);

            SaveLaunchApps();
            return null;
        }

        private object CmdUninstallApp(IpcMessage msg)
        {
            var key = msg.LaunchKey ?? throw new Exception("UninstallApp requires LaunchKey");
            CmdKillAllOfType(msg);
            _launchApps.RemoveAll(a => a.LaunchKey == key);
            SaveLaunchApps();
            return null;
        }

        private object CmdGetVolumeLevel()
        {
            return GetMasterVolume();
        }

        private object CmdSetVolumeLevel(IpcMessage msg)
        {
            if (msg.PayloadJson == null)
                throw new Exception("set_VolumeLevel requires parameters");

            var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
            if (parms == null || parms.Length == 0)
                throw new Exception("Invalid volume value");
            var scalar = parms[0].ValueKind == JsonValueKind.Number
                ? (float)parms[0].GetDouble()
                : throw new Exception("Invalid volume value");
            SetMasterVolume(Math.Max(0f, Math.Min(1f, scalar)));
            return null;
        }

        private object CmdFullUpdate() =>
            new FullUpdateData
            {
                InstalledApps = _launchApps.ToArray(),
                LaunchedApps  = (LaunchedAppData[])CmdGetLaunchedApps(),
                VolumeLevel   = GetMasterVolume()
            };

        private object CmdInitiateInstallProduct(IpcMessage msg)
        {
            // ILauncherService.InitiateInstallProduct() takes no parameters.
            // It just returns a tracking Guid. The actual game registration
            // happens via InstallApp(LaunchData) in a separate RPC call.
            var callId   = Guid.NewGuid().ToString("N");
            var progress = new OutOfBandProgress
            {
                PercentComplete = 100,
                Status          = "Complete",
                IsCompleted     = true
            };

            lock (_installLock) _installProgress[callId] = progress;

            return callId;
        }

        private object CmdGetOutOfBandProgress(IpcMessage msg)
        {
            var callId = msg.PayloadJson != null
                ? JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson)?[0].GetString()
                : null;

            if (callId == null)
                throw new Exception("GetOutOfBandProgress requires a callId");

            lock (_installLock)
            {
                if (_installProgress.TryGetValue(callId, out var prog)) return prog;
            }

            return new OutOfBandProgress
            {
                PercentComplete = 0,
                Status          = "Unknown call ID",
                IsCompleted     = true
            };
        }

        private object CmdShutdown(IpcMessage msg)
        {
            bool doRestart = false;
            if (msg.PayloadJson != null)
            {
                try
                {
                    var parms = JsonSerializer.Deserialize<JsonElement[]>(msg.PayloadJson);
                    if (parms?.Length > 0 && parms[0].ValueKind == JsonValueKind.True)
                        doRestart = true;
                }
                catch { /* default to shutdown */ }
            }

            CmdKillAllApps();
            var flag = doRestart ? "/r" : "/s";
            new Thread(() =>
            {
                Thread.Sleep(2000);
                Process.Start("shutdown", $"{flag} /t 0");
            }) { IsBackground = true }.Start();
            return null;
        }

        // ── Helpers ───────────────────────────────────────────────────────────

        private LaunchData FindApp(string launchKey) =>
            _launchApps.FirstOrDefault(a =>
                string.Equals(a.LaunchKey, launchKey, StringComparison.OrdinalIgnoreCase));

        private void StartWatcher(LaunchData app, Process process)
        {
            new Thread(() =>
            {
                process.WaitForExit();
                if (_stopping) return;

                bool stillTracked;
                lock (_processLock)
                {
                    stillTracked = _runningProcesses.TryGetValue(
                        app.LaunchKey, out var procs) && procs.Contains(process);
                }

                if (!stillTracked) return;

                Thread.Sleep(2000);
                try { CmdLaunchApp(new IpcMessage { LaunchKey = app.LaunchKey }); }
                catch { }
            })
            { IsBackground = true, Name = $"Watcher-{app.LaunchKey}" }.Start();
        }

        // ── Volume Control ────────────────────────────────────────────────────
        // The Console stores/retrieves volume as a float (0.0–1.0 scalar).
        // We cache the exact value the Console sent so get returns the same
        // value without CoreAudio roundtrip quantization error.

        private float _cachedVolumeScalar = 1.0f;

        private float GetMasterVolume()
        {
            return _cachedVolumeScalar;
        }

        private void SetMasterVolume(float scalar)
        {
            _cachedVolumeScalar = scalar;

            // Try nircmd.exe first (legacy compatibility)
            var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe");
            if (File.Exists(nircmd))
            {
                Process.Start(nircmd, $"setsysvolume {(int)(scalar * 65535)}")?.Dispose();
                return;
            }

            // Windows Core Audio API fallback (Vista+, no external dependencies)
            CoreAudio.SetMasterScalar(scalar);
        }

        // ── Response helpers ──────────────────────────────────────────────────

        private static IpcResponse Ok(object data = null)   => new() { Success = true,  Data    = data };
        private static IpcResponse Fail(string msg)         => new() { Success = false, Message = msg  };

        protected override void OnLoad(EventArgs e)
        {
            base.OnLoad(e);
            Visible = false;
        }

        protected override void Dispose(bool disposing)
        {
            if (disposing) { _trayIcon?.Dispose(); _trayMenu?.Dispose(); }
            base.Dispose(disposing);
        }
    }

    // ── Agent-local data types ────────────────────────────────────────────────
    // These are used internally by the Agent (XML config, process management).
    // They are NOT the BinaryFormatter wire types (Tesla.Net namespace).

    /// <summary>
    /// Agent-internal representation of a launchable app.
    /// Loaded from LaunchApps.xml via XmlSerializer.
    /// </summary>
    [Serializable]
    public class LaunchData
    {
        public string LaunchKey        { get; set; }
        public string DisplayName      { get; set; }
        public string WorkingDirectory { get; set; }
        public string ExeFile          { get; set; }
        public string Arguments        { get; set; }
        public bool   AutoRestart      { get; set; }
    }

    /// <summary>
    /// Agent-internal tracking of a running process.
    /// Returned via JSON IPC (string LaunchKey, not Guid).
    /// </summary>
    [Serializable]
    public class LaunchedAppData
    {
        public string LaunchKey { get; set; }
        public int    ProcessId { get; set; }
    }

    /// <summary>
    /// Agent-internal full state snapshot.
    /// Returned via JSON IPC for the FullUpdate command.
    /// </summary>
    [Serializable]
    public class FullUpdateData
    {
        public LaunchData[]     InstalledApps { get; set; }
        public LaunchedAppData[] LaunchedApps { get; set; }
        public float            VolumeLevel   { get; set; }
    }

    [Serializable]
    public class OutOfBandProgress
    {
        public int    PercentComplete { get; set; }
        public string Status         { get; set; }
        public bool   IsCompleted    { get; set; }
    }

    // ── Windows Core Audio API — minimal COM interop (Vista+) ─────────────────
    // No external dependencies. Vtable slot order matches the SDK headers exactly.
    // Methods we don't call are declared as void stubs to preserve vtable offsets.

    internal static class CoreAudio
    {
        internal static float GetMasterScalar()
        {
            var ep = GetEndpointVolume();
            try   { ep.GetMasterVolumeLevelScalar(out float v); return v; }
            finally { ReleaseAll(ep); }
        }

        internal static void SetMasterScalar(float scalar)
        {
            var ep = GetEndpointVolume();
            try   { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); }
            finally { ReleaseAll(ep); }
        }

        private static IAudioEndpointVolume GetEndpointVolume()
        {
            var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass();
            try
            {
                enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out var device);
                try
                {
                    var iid = typeof(IAudioEndpointVolume).GUID;
                    device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out var obj);
                    return (IAudioEndpointVolume)obj;
                }
                finally { Marshal.ReleaseComObject(device); }
            }
            finally { Marshal.ReleaseComObject(enumerator); }
        }

        private static void ReleaseAll(object obj)
        {
            if (obj != null) Marshal.ReleaseComObject(obj);
        }
    }

    [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")]
    internal class MMAudioEnumeratorComClass { }

    [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"),
     InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    internal interface IMMDeviceEnumerator
    {
        void _unused_EnumAudioEndpoints();  // slot 0 — not used
        [PreserveSig]
        int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint);
    }

    [Guid("D666063F-1587-4E43-81F1-B948E807363F"),
     InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    internal interface IMMDevice
    {
        [PreserveSig]
        int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams,
            [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer);
    }

    // Vtable order (after IUnknown): RegisterControlChangeNotify(0),
    // UnregisterControlChangeNotify(1), GetChannelCount(2),
    // SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4),
    // GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6)
    [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"),
     InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
    internal interface IAudioEndpointVolume
    {
        void _unused_RegisterControlChangeNotify();    // slot 0
        void _unused_UnregisterControlChangeNotify();  // slot 1
        void _unused_GetChannelCount();                // slot 2
        [PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx);
        [PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx);
        [PreserveSig] int GetMasterVolumeLevel(out float levelDB);
        [PreserveSig] int GetMasterVolumeLevelScalar(out float level);
    }
}
