// =============================================================================
// TeslaLauncher — Secure Pod Configuration
// =============================================================================
// Implements the Tesla secure pod configuration protocol (reverse-engineered
// from TeslaSecureConfiguration.dll, Elsewhen Studios LLC).
//
// Protocol summary:
//   1. Assign temp IP, generate RequestId + Passphrase
//   2. Broadcast UDP RQST beacon on port 53291
//   3. Display codes on screen and COM2 plasma display
//   4. Console sends AES-encrypted network config via UDP RPLY on port 53292
//   5. Apply static IP, DNS, hostname via netsh
//   6. TCP handshake on port 53292: OFB crypto negotiation + RSA key exchange
//   7. Save 32-byte session key to TeslaKeyStore.key
// =============================================================================

using System;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Net.NetworkInformation;
using System.Text;
using System.Security.Cryptography;
using System.Linq;
using System.Threading;
using System.Diagnostics;
using Microsoft.Win32;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Formatters.Binary;
#if WINFORMS
using System.Windows.Forms;
using System.Drawing;
using System.Runtime.InteropServices;
#endif

namespace TeslaSecureConfig
{
    // ---- Wire protocol constants ----
    internal static class Proto
    {
        public const string RQST = "RQST";
        public const string RPLY = "RPLY";
        public const string CONF = "CONF";
        public const string DONE = "DONE";

        public const int UdpBroadcastPort   = 53291;   // Console listens here
        public const int UdpReplyPort       = 53292;   // Cockpit listens here for Console RPLY (UDP broadcast)
        public const int UdpBeaconIntervalMs= 2000;

        public const string TempIp          = "172.16.0.100";
        public const string TempBcast       = "172.31.255.255";  // directed broadcast for 172.16.0.0/12
        public const string TempMask        = "255.240.0.0";

        public const int PassphraseLength   = 5;
        public const int RequestIdLength    = 3;
        public const string Alphabet        = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";

        public const int RsaKeySize         = 2048;
        public const int Pbkdf2Iterations   = 1000;
        public const int AesKeyBytes        = 32;       // 256-bit AES

        // Static salt extracted from TeslaSecureConfiguration.dll FieldRVA
        public static readonly byte[] PassphraseSalt = new byte[]
        {
            0x17, 0xab, 0x51, 0xd9, 0xec, 0xd1, 0xd4, 0x74,
            0xa9, 0x09, 0x4a, 0x34, 0x27, 0xfb, 0x1f, 0xf2,
            0xde, 0xc4, 0xf9, 0xf1, 0xa6, 0xd8, 0x9e, 0xda,
            0x15, 0x11, 0x47, 0x65, 0x32, 0xe7, 0xe7, 0xef
        };

        public const string ComPort         = "COM2";
        public const int    ComBaud         = 9600;
        public const string PlasmaFont      = "Verdana";

        public const string RegComputerName = @"SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName";
        public const string RegTcpipParams  = @"SYSTEM\CurrentControlSet\services\Tcpip\Parameters";
    }

    // ---- Network configuration returned from Console ----
    public class PodNetworkConfig
    {
        public IPAddress Address  { get; set; }
        public IPAddress Mask     { get; set; }
        public IPAddress Gateway  { get; set; }
        public IPAddress Dns      { get; set; }
        public string    HostName { get; set; }
    }

#if WINFORMS
    // ---- Passcode display form ----
    // Request ID: sent in beacon, Console shows it to identify this cockpit.
    // Passphrase: displayed HERE only. Operator reads it and types it into the
    //             Console. Console returns it inside the encrypted CONF to prove
    //             it is configuring the correct pod.
    internal class PasscodeDisplayForm : Form
    {
        public PasscodeDisplayForm(string requestId, string passphrase)
        {
            SuspendLayout();

            Text            = "Your Configuration Passcode";
            FormBorderStyle = FormBorderStyle.FixedDialog;
            StartPosition   = FormStartPosition.CenterScreen;
            ClientSize      = new Size(480, 220);
            MaximizeBox     = false;
            MinimizeBox     = false;

            var boldFont  = new Font("Arial Narrow", 24f, FontStyle.Bold,    GraphicsUnit.Point);
            var labelFont = new Font("Arial Narrow", 14f, FontStyle.Regular,  GraphicsUnit.Point);
            var noteFont  = new Font("Arial Narrow", 10f, FontStyle.Italic,   GraphicsUnit.Point);

            // Request ID row
            var lblRqstTitle = new Label { Text = "Request ID:", AutoSize = true,
                Location = new Point(20, 24), Font = labelFont };
            var lblRqstVal = new Label { Text = requestId, AutoSize = true,
                Location = new Point(200, 20), Font = boldFont };

            // Passphrase row
            var lblPassTitle = new Label { Text = "Passphrase:", AutoSize = true,
                Location = new Point(20, 100), Font = labelFont };
            var lblPassVal = new Label { Text = passphrase, AutoSize = true,
                Location = new Point(200, 96), Font = boldFont,
                ForeColor = System.Drawing.Color.DarkRed };

            // Instruction note
            var lblNote = new Label
            {
                Text      = "Console will show the Request ID above.\r\n" +
                            "Read the Passphrase (red) to the operator,\r\n" +
                            "who types it into the Console to authorise this pod.",
                AutoSize  = true,
                Location  = new Point(20, 158),
                Font      = noteFont,
                ForeColor = System.Drawing.Color.Gray
            };

            Controls.AddRange(new Control[]
                { lblRqstTitle, lblRqstVal, lblPassTitle, lblPassVal, lblNote });

            ResumeLayout(false);
            PerformLayout();
        }
    }

#endif
    // ---- PlasmaIO wrapper (mirrors PlasmaIO.dll PlasmaDisplay API) ----
    internal sealed class PlasmaWriter : IDisposable
    {
        private System.IO.Ports.SerialPort _port;
        private bool _disposed;

        public PlasmaWriter(string comPort, int baud = 9600)
        {
            try
            {
                _port = new System.IO.Ports.SerialPort(comPort, baud,
                    System.IO.Ports.Parity.None, 8, System.IO.Ports.StopBits.One);
                _port.Open();
                ClearAll();
            }
            catch (Exception ex)
            {
                // Plasma display is optional - log but continue
                System.Diagnostics.Debug.WriteLine($"PlasmaIO init failed: {ex.Message}");
                _port = null;
            }
        }

        // ClearAll: send ESC J (clear display) as per Plasma protocol
        public void ClearAll()
        {
            if (_port == null || !_port.IsOpen) return;
            _port.BaseStream.WriteByte(0x1B);  // ESC
            _port.BaseStream.WriteByte(0x4A);  // J  (clear screen)
            _port.BaseStream.Flush();
        }

        public void WriteLine(string format, params object[] args)
        {
            if (_port == null || !_port.IsOpen) return;
            var text = string.Format(format, args) + "\r\n";
            var bytes = Encoding.ASCII.GetBytes(text);
            _port.BaseStream.Write(bytes, 0, bytes.Length);
            _port.BaseStream.Flush();
        }

        public void Dispose()
        {
            if (!_disposed)
            {
                _disposed = true;
                try { _port?.Close(); } catch { }
            }
        }
    }

    // ---- Crypto helpers ----
    internal static class CryptoHelper
    {
        /// Derive AES key from passphrase using PBKDF2 with the hard-coded salt.
        /// COMPATIBILITY: this MUST use the SHA1-default Rfc2898DeriveBytes(string,
        /// byte[], int) overload. The Console derives the same session key with the
        /// identical SHA1-default PBKDF2 (Tesla.PodConfigurationServer.GenerateKeyFromPassphrase),
        /// so switching to a SHA256 overload — as SYSLIB0041 suggests on net8 — would
        /// silently break the secure-config key handshake. Do not "modernize" this.
#pragma warning disable SYSLIB0041 // SHA1-default PBKDF2 is required for Console wire compatibility
        public static byte[] DeriveKey(string passphrase)
        {
            var pbkdf2 = new Rfc2898DeriveBytes(passphrase,
                Proto.PassphraseSalt, Proto.Pbkdf2Iterations);
            return pbkdf2.GetBytes(Proto.AesKeyBytes);
        }
#pragma warning restore SYSLIB0041

        /// Encrypt data with AES-256 (CBC mode)
        public static byte[] Encrypt(byte[] data, byte[] key)
        {
            using var aes = Aes.Create();
            aes.Mode = CipherMode.CBC;
            aes.Key  = key;
            aes.GenerateIV();
            var iv = aes.IV;
            using var enc = aes.CreateEncryptor();
            using var ms  = new MemoryStream();
            using var cs  = new CryptoStream(ms, enc, CryptoStreamMode.Write);
            cs.Write(data, 0, data.Length);
            cs.FlushFinalBlock();
            var cipher = ms.ToArray();
            // Prepend IV
            var result = new byte[iv.Length + cipher.Length];
            Buffer.BlockCopy(iv, 0, result, 0, iv.Length);
            Buffer.BlockCopy(cipher, 0, result, iv.Length, cipher.Length);
            return result;
        }

        /// Decrypt data with AES-256 (CBC mode), IV prepended
        public static byte[] Decrypt(byte[] data, byte[] key)
        {
            using var aes = Aes.Create();
            aes.Mode = CipherMode.CBC;
            aes.Key  = key;
            var iv = new byte[aes.BlockSize / 8];
            Buffer.BlockCopy(data, 0, iv, 0, iv.Length);
            aes.IV = iv;
            using var dec = aes.CreateDecryptor();
            using var ms  = new MemoryStream();
            using var cs  = new CryptoStream(ms, dec, CryptoStreamMode.Write);
            cs.Write(data, iv.Length, data.Length - iv.Length);
            cs.FlushFinalBlock();
            return ms.ToArray();
        }
    }

    // ── OFB Duplex Stream ──────────────────────────────────────────────────
    //
    // The original OFBCryptoStream wraps Rijndael-ECB (CipherMode=2) and implements OFB
    // (Output Feedback) mode manually: keystream is generated by repeatedly encrypting the
    // feedback register with the ECB block cipher, then XORing data with the keystream.
    //
    // For a full-duplex TCP connection, two independent keystream generators are maintained:
    //   Write direction (pod→console): seeded from the pod's generated IV
    //   Read  direction (console→pod): seeded from the console's sent IV
    //
    // Both directions use the same PBKDF2-derived AES key.
    internal sealed class OFBDuplexStream : Stream
    {
        private readonly NetworkStream _inner;

        // Write-direction (pod→console) OFB state
        private readonly Aes            _writeAes;
        private readonly ICryptoTransform _writeXform;
        private readonly byte[]         _writeFb = new byte[16];   // feedback register (starts as IV)
        private readonly byte[]         _writeKs = new byte[16];   // current keystream block
        private int                     _wPos    = 16;             // 16 = "generate new block on next byte"

        // Read-direction (console→pod) OFB state
        private readonly Aes            _readAes;
        private readonly ICryptoTransform _readXform;
        private readonly byte[]         _readFb  = new byte[16];
        private readonly byte[]         _readKs  = new byte[16];
        private int                     _rPos    = 16;

        /// <param name="inner">Underlying NetworkStream (not owned — caller disposes).</param>
        /// <param name="key">32-byte PBKDF2 key derived from the passphrase.</param>
        /// <param name="writeIv">16-byte IV for pod→console keystream (pod-generated).</param>
        /// <param name="readIv">16-byte IV for console→pod keystream (console-generated).</param>
        public OFBDuplexStream(NetworkStream inner, byte[] key, byte[] writeIv, byte[] readIv)
        {
            _inner = inner;
            Buffer.BlockCopy(writeIv, 0, _writeFb, 0, 16);
            Buffer.BlockCopy(readIv,  0, _readFb,  0, 16);

            _writeAes         = Aes.Create();
            _writeAes.Mode    = CipherMode.ECB;
            _writeAes.Padding = PaddingMode.None;
            _writeAes.Key     = key;
            _writeXform       = _writeAes.CreateEncryptor();

            _readAes          = Aes.Create();
            _readAes.Mode     = CipherMode.ECB;
            _readAes.Padding  = PaddingMode.None;
            _readAes.Key      = key;
            _readXform        = _readAes.CreateEncryptor();
        }

        private byte NextWriteByte()
        {
            if (_wPos == 16)
            {
                _writeXform.TransformBlock(_writeFb, 0, 16, _writeKs, 0);
                Buffer.BlockCopy(_writeKs, 0, _writeFb, 0, 16);
                _wPos = 0;
            }
            return _writeKs[_wPos++];
        }

        private byte NextReadByte()
        {
            if (_rPos == 16)
            {
                _readXform.TransformBlock(_readFb, 0, 16, _readKs, 0);
                Buffer.BlockCopy(_readKs, 0, _readFb, 0, 16);
                _rPos = 0;
            }
            return _readKs[_rPos++];
        }

        public override int Read(byte[] buffer, int offset, int count)
        {
            int n = _inner.Read(buffer, offset, count);
            for (int i = 0; i < n; i++)
                buffer[offset + i] ^= NextReadByte();
            return n;
        }

        public override void Write(byte[] buffer, int offset, int count)
        {
            var enc = new byte[count];
            for (int i = 0; i < count; i++)
                enc[i] = (byte)(buffer[offset + i] ^ NextWriteByte());
            _inner.Write(enc, 0, count);
        }

        public override void Flush() => _inner.Flush();

        public override bool CanRead  => true;
        public override bool CanWrite => true;
        public override bool CanSeek  => false;

        public override long Length   => throw new NotSupportedException();
        public override long Position
        {
            get => throw new NotSupportedException();
            set => throw new NotSupportedException();
        }
        public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
        public override void SetLength(long value)                 => throw new NotSupportedException();

        // Delegate timeout properties to the inner NetworkStream.
        public override bool CanTimeout   => true;
        public override int  ReadTimeout  { get => _inner.ReadTimeout;  set => _inner.ReadTimeout  = value; }
        public override int  WriteTimeout { get => _inner.WriteTimeout; set => _inner.WriteTimeout = value; }

        protected override void Dispose(bool disposing)
        {
            if (disposing)
            {
                _writeXform?.Dispose();
                _readXform?.Dispose();
                _writeAes?.Dispose();
                _readAes?.Dispose();
                // _inner is not owned — do not dispose
            }
            base.Dispose(disposing);
        }
    }

    // ---- Main secure configuration orchestrator ----
    public sealed class PodSecureConfigurator : IDisposable
    {
        private int    _adapterIndex;  // IPv4 interface index — used with netsh index=N
        private string _adapterId;     // Adapter GUID — used for registry static-IP persistence
        private string _adapterName;   // Display name e.g. "Ethernet" — used for ipconfig /release
        private readonly Action<string> _log;
        private IPAddress _consoleAddress;  // Console sender IP from RPLY — used to re-resolve adapter

        private string _requestId;
        private string _passphrase;

        /// <summary>
        /// After the RSA key exchange, the 32-byte session key is stored here.
        /// The Service saves it to TeslaKeyStore.key and uses it for NegotiateCryptoStreams
        /// on the management port (53290) for all subsequent Console connections.
        /// </summary>
        public byte[] SessionKey { get; private set; }

        private System.Net.Sockets.Socket _replySocket;
        private Thread     _beaconThread;
        private Thread     _replyThread;
        private volatile bool _running;
        private ManualResetEventSlim _configReceived = new ManualResetEventSlim(false);
        private PodNetworkConfig _receivedConfig;

#if WINFORMS
        private PasscodeDisplayForm _displayForm;
#endif
        private PlasmaWriter _plasma;

        public PodSecureConfigurator(string adapterName = null, Action<string> logger = null)  // adapterName = display name hint or null for auto-detect
        {
            if (adapterName != null)
            {
                _adapterIndex = FindAdapterIndex(adapterName);
                _adapterId    = FindAdapterId(adapterName);
                _adapterName  = adapterName;
            }
            else
            {
                (_adapterIndex, _adapterId, _adapterName) = FindFirstEthernetAdapter();
            }
            _log = logger ?? (s => Debug.WriteLine(s));
        }

        // --- Entry point ---
        public PodNetworkConfig Configure(int timeoutMs = 300_000)
        {
            Log("Beginning configuration.");

            // Delete any stale configuring.json from a previous run so
            // the Agent doesn't display old codes before we generate new ones.
            try
            {
                var staleFile = Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
                    "TeslaLauncher", "configuring.json");
                if (File.Exists(staleFile)) File.Delete(staleFile);
            }
            catch { }

            // Step 1: Generate identifiers
            _requestId  = GenerateRandomString(Proto.RequestIdLength);
            _passphrase = GenerateRandomString(Proto.PassphraseLength);
            Log($"Waiting for configuration. Request ID: {_requestId}  Passphrase: {_passphrase}  (passphrase is NOT transmitted — operator reads it off screen)");

            // Write RequestId/Passphrase to a shared file so the Agent
            // (running in the user session) can display them on screen.
            try
            {
                var cfgDir = Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
                    "TeslaLauncher");
                Directory.CreateDirectory(cfgDir);
                File.WriteAllText(Path.Combine(cfgDir, "configuring.json"),
                    $"{{\"RequestId\":\"{_requestId}\",\"Passphrase\":\"{_passphrase}\"}}");
            }
            catch { /* best-effort — plasma/log still have the values */ }

            // Step 2: Assign temporary IP
            Log($"Configuring temporary IP address on \"{_adapterName}\".");
            if (!ConfigureTempIp(_adapterName))
            {
                Log("Could not initalize temporary IP address.");
                return null;
            }
            Log("Temp IP configured.");

            // Step 3: Show display (WinForms + Plasma)
            ShowPasscodeDisplay();

            // Step 4: Start UDP beacon.
            // Bind to the temp IP so broadcasts go out through the correct
            // Ethernet adapter. netsh returns before the kernel has finished
            // assigning the address, so retry for up to 5 seconds.
            _running = true;

            // No longer waiting for the temp IP to propagate before broadcasting.
            // BeaconLoop uses per-interface sockets bound to each adapter's actual current
            // IP so Windows routes each broadcast out the correct physical NIC.
            // The temp IP assignment still runs (source=static disables DHCP for TCP),
            // but the beacon works immediately from whatever address the adapter has.
            Log("Starting beacon.");

            _beaconThread = new Thread(BeaconLoop) { Name = "BeaconThread", IsBackground = true };
            _beaconThread.Start();

            // Step 5: Start UDP listener on port 53292 for RPLY from Console.
            // The Console broadcasts "RPLY" + AES-encrypted CONF as a 68-byte UDP datagram
            // to 255.255.255.255:53292. We receive it, derive the AES key from our locally-
            // generated passphrase (operator typed it into the Console), decrypt and apply.
            try
            {
                _replySocket = new System.Net.Sockets.Socket(
                    System.Net.Sockets.AddressFamily.InterNetwork,
                    System.Net.Sockets.SocketType.Dgram,
                    System.Net.Sockets.ProtocolType.Udp);
                _replySocket.SetSocketOption(
                    System.Net.Sockets.SocketOptionLevel.Socket,
                    System.Net.Sockets.SocketOptionName.ReuseAddress, true);
                _replySocket.EnableBroadcast = true;
                _replySocket.Bind(new IPEndPoint(IPAddress.Any, Proto.UdpReplyPort));
            }
            catch (SocketException ex)
            {
                Log($"Could not bind UDP reply socket on port {Proto.UdpReplyPort}: {ex.Message}");
                _running = false;
                return null;
            }
            _replyThread = new Thread(UdpReplyLoop) { Name = "UdpReplyThread", IsBackground = true };
            _replyThread.Start();

            Log("Broadcasting... waiting for packets.");

            // Wait for config
            bool received = _configReceived.Wait(timeoutMs);
            _running = false;

            // Hide display
#if WINFORMS
            try { _displayForm?.Invoke(new Action(() => _displayForm.Close())); } catch { }
#endif
            _plasma?.Dispose();

            if (!received || _receivedConfig == null)
            {
                Log("Timed out waiting for configuration.");
                return null;
            }

            Log("Packets received.");

            // Step 6: Open TCP listener on 0.0.0.0:53292 BEFORE changing the IP.
            //
            // After sending RPLY the Console immediately ARPs for the NEW IP it put
            // in the payload (not the beacon source IP). It then connects TCP to that
            // new IP:53292 to confirm the cockpit applied the config.
            //
            // Binding the TCP listener to 0.0.0.0 first means it is already waiting
            // when the IP changes. Once netsh applies the new address and the Console's
            // ARP gets a reply, it connects TCP — and the listener accepts it.
            Log("Confirming configuration to Console.");
            try { _replySocket?.Close(); _replySocket = null; } catch { }

            // Re-resolve the adapter using the TARGET IP from the RPLY config.
            //
            // The Console may route its RPLY broadcast through a secondary NIC that is
            // NOT on the pod's network (e.g. a 192.168.1.x NIC instead of the 10.0.x
            // NIC that actually connects to the pods).  Using the Console's sender IP
            // (_consoleAddress) for adapter selection therefore fails whenever the Console
            // has multiple NICs.
            //
            // The target IP in the RPLY config is always on the pod's network — the
            // Console operator assigns it from the same IP range.  We find the local NIC
            // whose current IP shares the same subnet as the target: that is the correct
            // NIC to reconfigure.
            if (_receivedConfig != null)
                ResolveAdapterForTargetIp(_receivedConfig.Address, _receivedConfig.Mask);

            var tcpConf = new System.Net.Sockets.TcpListener(IPAddress.Any, Proto.UdpReplyPort);
            tcpConf.Start();

            // Step 7: Apply network configuration (IP changes here).
            // The listener is already bound so it will accept the Console's connection
            // once the new IP is live and the ARP resolves.
            Log($"Attempting to configure final IP.");
            if (!ApplyNetworkConfig(_adapterId, _adapterName, _receivedConfig))
            {
                Log("Could not initalize final IP address.");
                tcpConf.Stop();
                return null;
            }

            Log("Setting host name.");
            SetHostName(_receivedConfig.HostName);

            // Step 8: Accept Console connection and send DONE.
            SendTcpConfirmation(tcpConf);

            Log("...Configured");

            // Clean up the shared file so the Agent doesn't show stale data
            try
            {
                var cfgFile = Path.Combine(
                    Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
                    "TeslaLauncher", "configuring.json");
                if (File.Exists(cfgFile)) File.Delete(cfgFile);
            }
            catch { }

            return _receivedConfig;
        }

        // --- UDP beacon loop ---
        // On Windows, a socket bound to 0.0.0.0 sending to 255.255.255.255
        // is routed through whichever interface has the lowest metric —
        // which is often a virtual or loopback adapter, silently discarding
        // the packet before it reaches the physical wire.
        //
        // Fix: enumerate every live Ethernet interface, bind a separate socket
        // to each one's actual current IP, and send to that subnet's directed
        // broadcast address.  This forces each datagram out the correct NIC.
        private void BeaconLoop()
        {
            byte[] payload = BuildBeaconPayload();

            // On boot, the service may start before the NIC driver has fully initialised.
            // Wait up to 60 s for at least one UP Ethernet interface with a real, *bindable* IPv4
            // address.  Checking OperationalStatus+UnicastAddresses is not sufficient — after
            // "netsh interface ip set address ... static", the new IP can appear in the interface
            // list for several seconds before the kernel routing table commits it, causing socket
            // Bind() to fail with WSAEADDRNOTAVAIL.  We probe-bind a throwaway UDP socket to
            // confirm the address is actually usable before leaving the wait loop.
            int waitSecs = 0;
            while (_running)
            {
                bool ready = false;
                foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
                {
                    if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
                    if (nic.OperationalStatus    != OperationalStatus.Up)          continue;
                    foreach (var ua in nic.GetIPProperties().UnicastAddresses)
                    {
                        if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
                        if (IPAddress.IsLoopback(ua.Address)) continue;
                        // Verify the address is actually bindable, not just listed
                        try
                        {
                            using var probe = new System.Net.Sockets.Socket(
                                System.Net.Sockets.AddressFamily.InterNetwork,
                                System.Net.Sockets.SocketType.Dgram,
                                System.Net.Sockets.ProtocolType.Udp);
                            probe.Bind(new IPEndPoint(ua.Address, 0));
                            ready = true;
                        }
                        catch { /* not committed by kernel yet — keep waiting */ }
                        if (ready) break;
                    }
                    if (ready) break;
                }
                if (ready) break;
                if (waitSecs == 0) Log("Waiting for network interface to obtain a bindable IP address...");
                waitSecs++;
                if (waitSecs >= 60) { Log("Warning: no interface had a bindable IP after 60 s — broadcasting anyway."); break; }
                Thread.Sleep(1000);
            }

            while (_running)
            {
                SendBeaconOnAllInterfaces(payload);
                Thread.Sleep(Proto.UdpBeaconIntervalMs);
            }
        }

        private byte[] BuildBeaconPayload()
        {
            // RQST beacon — 13 bytes:
            //   [0:4]   "RQST"
            //   [4:10]  6-byte raw MAC of the physical Ethernet adapter
            //   [10:13] RequestId (3 ASCII chars)
            byte[] macBytes = GetAdapterMacBytes();
            using var ms = new MemoryStream(13);
            using var w  = new BinaryWriter(ms, Encoding.ASCII);
            w.Write(Encoding.ASCII.GetBytes(Proto.RQST));
            w.Write(macBytes);
            w.Write(Encoding.ASCII.GetBytes(_requestId.PadRight(Proto.RequestIdLength)));
            return ms.ToArray();
        }

        private void SendBeaconOnAllInterfaces(byte[] payload)
        {
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
                if (nic.OperationalStatus    != OperationalStatus.Up)          continue;

                var ipProps = nic.GetIPProperties();
                foreach (var ua in ipProps.UnicastAddresses)
                {
                    if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
                    if (IPAddress.IsLoopback(ua.Address)) continue;

                    // Send to the limited broadcast address (255.255.255.255).
                    //
                    // The Console can be on any IP subnet — in production it is on
                    // 10.0.0.x while the pod temp IP is 172.16.0.x.  A directed
                    // subnet broadcast (e.g. 172.31.255.255) never crosses the subnet
                    // boundary so the Console would not receive it.
                    // Bind to this NIC's IP so Windows routes via the physical adapter
                    try
                    {
                        using var sock = new System.Net.Sockets.Socket(
                            System.Net.Sockets.AddressFamily.InterNetwork,
                            System.Net.Sockets.SocketType.Dgram,
                            System.Net.Sockets.ProtocolType.Udp);
                        sock.EnableBroadcast = true;
                        sock.Bind(new IPEndPoint(ua.Address, 0));
                        sock.SendTo(payload, new IPEndPoint(IPAddress.Broadcast, Proto.UdpBroadcastPort));
                    }
                    catch (Exception ex)
                    {
                        Log($"Beacon send error on {ua.Address}: {ex.Message}");
                    }
                }
            }
        }

        /// <summary>
        /// Returns the 6-byte raw MAC of the adapter holding the temp IP.
        /// Falls back to the first operational Ethernet adapter.
        /// </summary>
        private static byte[] GetAdapterMacBytes()
        {
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
                if (nic.OperationalStatus    != OperationalStatus.Up)          continue;

                foreach (var ua in nic.GetIPProperties().UnicastAddresses)
                {
                    if (ua.Address.ToString() == Proto.TempIp)
                        return nic.GetPhysicalAddress().GetAddressBytes();
                }
            }

            // Fallback: first operational Ethernet adapter
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
                if (nic.OperationalStatus    != OperationalStatus.Up)          continue;

                var mac = nic.GetPhysicalAddress().GetAddressBytes();
                if (mac.Length == 6) return mac;
            }

            return new byte[6]; // all-zero fallback
        }

        // --- UDP RPLY listener ---
        // Listens for AES-encrypted RPLY packets from the Console on port 53292.
        // Key = PBKDF2(passphrase, salt, 1000, 32). Format: "RPLY"(4) + IV(16) + ciphertext(48).
        private void UdpReplyLoop()
        {
            Log("Waiting for console to connect.");
            var buf = new byte[4096];
            _replySocket.ReceiveTimeout = 2000;

            while (_running)
            {
                try
                {
                    EndPoint remoteEp = new IPEndPoint(IPAddress.Any, 0);
                    int len = _replySocket.ReceiveFrom(buf, ref remoteEp);
                    if (len < 4) continue;

                    string tag = Encoding.ASCII.GetString(buf, 0, 4);
                    if (tag != Proto.RPLY) continue;

                    // Payload after "RPLY" tag is the AES-encrypted CONF block
                    int    cipherLen  = len - 4;
                    byte[] cipherText = new byte[cipherLen];
                    Buffer.BlockCopy(buf, 4, cipherText, 0, cipherLen);

                    // Derive AES key from our passphrase (operator typed this into Console)
                    byte[] aesKey  = CryptoHelper.DeriveKey(_passphrase);
                    byte[] confData;
                    try   { confData = CryptoHelper.Decrypt(cipherText, aesKey); }
                    catch { Log("RPLY decrypt failed — AES error, likely wrong passphrase key."); continue; }

                    var config = ParseConf(confData);
                    if (config == null) { Log("RPLY parsed but config invalid — skipping."); continue; }

                    Log($"CONF accepted: IP {config.Address}, Mask {config.Mask}, " +
                        $"GW {config.Gateway}, DNS {config.Dns}, Host {config.HostName}");

                    _receivedConfig = config;
                    if (remoteEp is IPEndPoint rip && !IPAddress.Any.Equals(rip.Address))
                        _consoleAddress = rip.Address;
                    _configReceived.Set();
                    return;
                }
                catch (SocketException) { /* timeout — loop */ }
                catch (ObjectDisposedException) { break; }
                catch (Exception ex) { Log($"RPLY receive error: {ex.Message}"); }
            }
        }

        // --- TCP session: OFB negotiation + RSA key exchange ---
        // Uses Socket.Poll() because ReceiveTimeout doesn't affect AcceptTcpClient in .NET 6.
        private void SendTcpConfirmation(System.Net.Sockets.TcpListener tcpConf)
        {
            try
            {
                const int TimeoutMs = 60_000;   // 60 s — Console retries at ~0.5 s intervals

                Log("TCP port 53292 open — waiting for Console to connect...");

                bool ready = tcpConf.Server.Poll(TimeoutMs * 1000, System.Net.Sockets.SelectMode.SelectRead);
                if (!ready) { Log("TCP confirmation timed out."); return; }

                System.Net.Sockets.TcpClient client;
                try   { client = tcpConf.AcceptTcpClient(); }
                catch (SocketException) { Log("TCP accept failed."); return; }

                using (client)
                {
                    var netStream = client.GetStream();
                    netStream.WriteTimeout = 10_000;
                    netStream.ReadTimeout  = 30_000;

                    Log("Console connection received.  Negotiating...");

                    // ── 1. IV exchange ──────────────────────────────────────
                    var consoleIv = new byte[16];
                    TcpReadExact(netStream, consoleIv);

                    var podIv = new byte[16];
                    using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(podIv);
                    netStream.Write(podIv, 0, 16);
                    netStream.Flush();

                    // ── 2. OFB session setup ────────────────────────────────
                    // Each direction keyed from the OTHER side's IV
                    var aesKey = CryptoHelper.DeriveKey(_passphrase);

                    using var ofb = new OFBDuplexStream(netStream, aesKey,
                        writeIv: consoleIv,   // pod→console direction
                        readIv:  podIv);      // console→pod direction
                    ofb.WriteTimeout = 10_000;
                    ofb.ReadTimeout  = 30_000;

                    // ── 3. CONF handshake ───────────────────────────────────
                    // Both sides write "CONF" over OFB and verify the reply
                    // to confirm matching PBKDF2 keys.
                    var confMsg = Encoding.UTF8.GetBytes("CONF");
                    ofb.Write(confMsg, 0, confMsg.Length);
                    ofb.Flush();

                    var confReply = new byte[4];
                    TcpReadExact(ofb, confReply);
                    if (!confReply.SequenceEqual(confMsg))
                    {
                        Log($"CONF mismatch: got [{BitConverter.ToString(confReply)}], expected CONF — key/IV mismatch.");
                        return;
                    }
                    Log("Secure connection to console negotiated.");

                    // ── 4. RSA key exchange ─────────────────────────────────
                    using var rsa = RSA.Create(Proto.RsaKeySize);

                    Log("Sending console final key.");
                    using (var bw = new BinaryWriter(ofb, Encoding.UTF8, leaveOpen: true))
                    {
                        bw.Write(rsa.ToXmlString(false));  // public key only
                        bw.Flush();
                    }

                    Log("Receiving console key.");
                    byte[] sessionKey;
                    using (var br = new BinaryReader(ofb, Encoding.UTF8, leaveOpen: true))
                    {
                        int encLen  = br.ReadInt32();
                        byte[] enc  = br.ReadBytes(encLen);
                        sessionKey  = rsa.Decrypt(enc, RSAEncryptionPadding.Pkcs1);
                    }
                    Log($"Console key received ({sessionKey.Length} bytes).");

                    // Store session key — used for OFB on the management port (53290)
                    SessionKey = sessionKey;

                    // Persist in original KeyStore format: [1-byte length] + [key bytes]
                    var keyDir = Path.Combine(
                        Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
                        "TeslaLauncher");
                    Directory.CreateDirectory(keyDir);
                    var keyPath = Path.Combine(keyDir, "TeslaKeyStore.key");
                    using (var fs = File.Open(keyPath, FileMode.Create, FileAccess.Write, FileShare.None))
                    {
                        fs.WriteByte((byte)sessionKey.Length);
                        fs.Write(sessionKey, 0, sessionKey.Length);
                    }
                    Log($"Session key saved ({sessionKey.Length} bytes).");

                    Log("Console confirmed — configuration complete.");
                }
            }
            catch (Exception ex) { Log($"TCP confirmation error (non-fatal): {ex.Message}"); }
            finally { try { tcpConf?.Stop(); } catch { } }
        }

        /// <summary>Reads exactly buf.Length bytes from stream; throws IOException on EOF.</summary>
        private static void TcpReadExact(Stream stream, byte[] buf)
        {
            int off = 0;
            while (off < buf.Length)
            {
                int n = stream.Read(buf, off, buf.Length - off);
                if (n == 0) throw new IOException("TCP connection closed before all bytes received.");
                off += n;
            }
        }

        // Parse decrypted CONF payload: IP(4) + Mask(4) + GW(4) + DNS(4) + Hostname(ASCII)
        private PodNetworkConfig ParseConf(byte[] data)
        {
            try
            {
                if (data.Length < 16) return null;

                var addr = new IPAddress(new byte[] { data[0], data[1], data[2], data[3] });
                var mask = new IPAddress(new byte[] { data[4], data[5], data[6], data[7] });
                var gw   = new IPAddress(new byte[] { data[8], data[9], data[10], data[11] });
                var dns  = new IPAddress(new byte[] { data[12], data[13], data[14], data[15] });

                // Sanity-check: first octet of IP must be 1–223 (unicast range).
                // If decryption produced garbage (wrong passphrase), this will fail silently —
                // the cockpit just keeps broadcasting and waiting for the correct RPLY.
                if (data[0] == 0 || data[0] >= 224)
                {
                    Log($"RPLY: decryption produced non-unicast IP first-octet ({data[0]}) — ignoring (wrong passphrase in Console?).");
                    return null;
                }

                // Hostname is the printable ASCII content of the remaining bytes,
                // terminated at the first non-printable character.
                var hostName = string.Empty;
                for (int i = 16; i < data.Length; i++)
                {
                    byte b = data[i];
                    if (b < 0x20 || b > 0x7e) break;
                    hostName += (char)b;
                }

                Log($"CONF decrypted: IP={addr} Mask={mask} GW={gw} DNS={dns} Host={hostName}");
                return new PodNetworkConfig { Address=addr, Mask=mask, Gateway=gw, Dns=dns, HostName=hostName };
            }
            catch (Exception ex) { Log($"ParseConf error: {ex.Message}"); return null; }
        }

        // --- Show passcode display ---
        private void ShowPasscodeDisplay()
        {
            _plasma = new PlasmaWriter(Proto.ComPort, Proto.ComBaud);
            _plasma.ClearAll();
            _plasma.WriteLine("Request ID: {0}", _requestId);
            _plasma.WriteLine("Passphrase: {0}", _passphrase);

#if WINFORMS
            // WinForms dialog — only shown when running as the Agent (user session)
            var t = new Thread(() =>
            {
                Application.EnableVisualStyles();
                Application.SetCompatibleTextRenderingDefault(false);
                _displayForm = new PasscodeDisplayForm(_requestId, _passphrase);
                _displayForm.ShowDialog();
            })
            { IsBackground = true };
            t.SetApartmentState(ApartmentState.STA);
            t.Start();
#endif
        }

        // --- Temp IP via netsh ---
        //
        // Uses the old-style "interface ip set address" command (not "interface ipv4")
        // because the old form directly calls SetAdapterIPAddress() and immediately
        // updates the running TCP/IP stack. The "interface ipv4" variant only updates
        // the persistent config and requires a network adapter restart to take effect.
        //
        // No gateway for the temp IP — it is only needed to receive the RPLY broadcast
        // on the same subnet; no routing is required at this stage.
        private static bool ConfigureTempIp(string adapterName)
        {
            return RunNetsh(
                $"interface ip set address \"{adapterName}\" " +
                $"static {Proto.TempIp} {Proto.TempMask}");
        }

        // --- Final IP via netsh + registry ---
        //
        // Uses the old-style "interface ip set address" command to immediately update
        // both the running stack and the persistent config. The registry writes provide
        // belt-and-suspenders persistence in case the DHCP client service ever re-asserts
        // on the next boot.
        private static bool ApplyNetworkConfig(string adapterId, string adapterName, PodNetworkConfig cfg)
        {
            // Set static IP + mask + gateway. The trailing "1" is the gateway metric.
            bool ok = RunNetsh(
                $"interface ip set address \"{adapterName}\" " +
                $"static {cfg.Address} {cfg.Mask} {cfg.Gateway} 1");
            if (!ok) return false;

            // Set primary DNS. "register=primary" ensures the host registers its DNS record.
            ok = RunNetsh(
                $"interface ip set dns \"{adapterName}\" " +
                $"static {cfg.Dns} primary");
            if (!ok) return false;

            // Belt-and-suspenders: write directly to the Tcpip registry key so the
            // static config is guaranteed to survive a reboot.
            PersistStaticIpRegistry(adapterId, cfg);
            return true;
        }

        private static void PersistStaticIpRegistry(string adapterId, PodNetworkConfig cfg)
        {
            string keyPath = $@"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces\{adapterId}";
            try
            {
                using var key = Registry.LocalMachine.OpenSubKey(keyPath, writable: true);
                if (key == null) { Log2($"Registry key not found: {keyPath}"); return; }

                key.SetValue("EnableDHCP",     0,                               RegistryValueKind.DWord);
                key.SetValue("IPAddress",      new[] { cfg.Address.ToString() }, RegistryValueKind.MultiString);
                key.SetValue("SubnetMask",     new[] { cfg.Mask.ToString() },    RegistryValueKind.MultiString);
                key.SetValue("DefaultGateway", new[] { cfg.Gateway.ToString() }, RegistryValueKind.MultiString);
                key.SetValue("GatewayMetric",  new[] { "0" },                    RegistryValueKind.MultiString);
                key.SetValue("NameServer",     cfg.Dns.ToString(),               RegistryValueKind.String);

                // Remove cached DHCP lease data that could re-enable DHCP on boot
                foreach (var val in new[] {
                    "DhcpIPAddress", "DhcpSubnetMask", "DhcpDefaultGateway",
                    "DhcpNameServer", "DhcpServer", "Lease",
                    "LeaseObtainedTime", "LeaseTerminatesTime" })
                {
                    try { key.DeleteValue(val); } catch { }
                }
                Log2("Static IP configuration persisted to registry.");
            }
            catch (Exception ex) { Log2($"Registry static-IP persist failed: {ex.Message}"); }
        }

        private static bool RunNetsh(string args)
        {
            var psi = new ProcessStartInfo("netsh", args)
            {
                UseShellExecute  = false,
                CreateNoWindow   = true
            };
            var p = Process.Start(psi);
            p.WaitForExit();
            return p.ExitCode == 0;
        }

        // --- Set hostname in registry ---
        private static void SetHostName(string hostName)
        {
            try
            {
                using var key1 = Registry.LocalMachine.OpenSubKey(Proto.RegComputerName, true);
                key1?.SetValue("ComputerName", hostName, RegistryValueKind.String);

                using var key2 = Registry.LocalMachine.OpenSubKey(Proto.RegTcpipParams, true);
                key2?.SetValue("NV Hostname", hostName, RegistryValueKind.String);

                Log2($"Host set to: {hostName}");
            }
            catch (Exception ex) { Log2($"Could not set host name: {ex.Message}"); }
        }

        private static void Log2(string s) => Debug.WriteLine(s);

        // --- Virtual adapter filter ---
        // Hyper-V, VirtualBox, VMware and similar virtualization layers expose
        // Ethernet-type adapters that are DHCP-enabled by default. Without this
        // filter, FindFirstEthernetAdapter() and IsMachineConfigured() both pick
        // up the virtual adapter (often the lowest-index one) instead of the
        // physical NIC, causing netsh to configure the wrong interface.
        private static bool IsVirtualAdapter(NetworkInterface nic)
        {
            var desc = nic.Description ?? "";
            var name = nic.Name        ?? "";
            // Common substrings found in virtual/software adapter descriptions.
            // Checked case-insensitively to cover all Windows localizations.
            string[] virtualMarkers = {
                "Virtual", "Hyper-V", "VMware", "VirtualBox",
                "Loopback", "Tunnel", "Miniport", "Wi-Fi Direct",
                "Bluetooth", "WAN Miniport", "Microsoft Kernel Debug"
            };
            foreach (var m in virtualMarkers)
                if (desc.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0 ||
                    name.IndexOf(m, StringComparison.OrdinalIgnoreCase) >= 0)
                    return true;
            // Physical NICs always have a 6-byte MAC. Virtual adapters sometimes
            // use locally-administered addresses (bit 1 of first octet set).
            var mac = nic.GetPhysicalAddress().GetAddressBytes();
            if (mac.Length == 6 && (mac[0] & 0x02) != 0) return true;  // locally administered
            return false;
        }

        // --- Re-resolve adapter using the target IP from the RPLY config ---
        // Using the Console's sender IP is unreliable when the Console has multiple NICs:
        // Windows may route the RPLY broadcast through a NIC on a completely different
        // subnet (e.g. 192.168.1.x instead of the 10.0.x NIC that faces the pods).
        // The TARGET IP assigned in the RPLY payload is always on the pod's network —
        // the Console operator picks it from the same range.  So we find the local NIC
        // whose current address is on the same subnet as the target: that is the correct
        // NIC to reconfigure, regardless of which Console NIC happened to send the RPLY.
        private void ResolveAdapterForTargetIp(IPAddress targetIp, IPAddress targetMask)
        {
            byte[] tBytes = targetIp.GetAddressBytes();
            byte[] mBytes = targetMask?.GetAddressBytes();
            if (mBytes == null || mBytes.Length != 4) mBytes = new byte[] { 255, 255, 255, 0 };
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (nic.OperationalStatus    != OperationalStatus.Up)          continue;
                if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
                if (IsVirtualAdapter(nic))                                     continue;
                foreach (var ua in nic.GetIPProperties().UnicastAddresses)
                {
                    if (ua.Address.AddressFamily != System.Net.Sockets.AddressFamily.InterNetwork) continue;
                    if (IPAddress.IsLoopback(ua.Address)) continue;
                    byte[] aBytes = ua.Address.GetAddressBytes();
                    bool same = true;
                    for (int i = 0; i < 4; i++)
                        if ((aBytes[i] & mBytes[i]) != (tBytes[i] & mBytes[i])) { same = false; break; }
                    if (!same) continue;
                    try
                    {
                        int idx = nic.GetIPProperties().GetIPv4Properties().Index;
                        Log($"Adapter resolved to [{idx}] {nic.Name} (same subnet as target {targetIp})");
                        _adapterIndex = idx;
                        _adapterId    = nic.Id;
                        _adapterName  = nic.Name;
                        return;
                    }
                    catch (NetworkInformationException) { }
                }
            }
            Log($"Warning: target IP {targetIp} not on any local subnet — keeping [{_adapterIndex}] {_adapterName}");
        }

        // Returns (IPv4 interface index, adapter GUID).
        // Index is used with `netsh interface ipv4 ... interface=N` (avoids name-quoting issues).
        // GUID is used for direct registry writes to persist static IP across reboots.
        private static (int index, string id, string name) FindFirstEthernetAdapter()
        {
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (nic.OperationalStatus    != OperationalStatus.Up)          continue;
                if (nic.NetworkInterfaceType != NetworkInterfaceType.Ethernet) continue;
                if (IsVirtualAdapter(nic))                                     continue;
                // Skip interfaces with no IPv4 unicast address (NIC Up but no IP yet)
                bool hasIp = false;
                foreach (var ua in nic.GetIPProperties().UnicastAddresses)
                    if (ua.Address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork
                        && !IPAddress.IsLoopback(ua.Address))
                    { hasIp = true; break; }
                if (!hasIp) continue;
                try
                {
                    int idx = nic.GetIPProperties().GetIPv4Properties().Index;
                    Log2($"Selected physical adapter: [{idx}] {nic.Name} / {nic.Description}");
                    return (idx, nic.Id, nic.Name);
                }
                catch (NetworkInformationException) { }
            }
            throw new InvalidOperationException("Could not find a physical Ethernet adapter with an IPv4 address.");
        }

        private static string FindAdapterId(string displayName)
        {
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (string.Equals(nic.Name, displayName, StringComparison.OrdinalIgnoreCase) ||
                    string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
                    return nic.Id;
            }
            return FindFirstEthernetAdapter().id;  // .name ignored in fallback
        }

        // Find index for a named adapter (fallback when caller passes a display name)
        private static int FindAdapterIndex(string displayName)
        {
            foreach (var nic in NetworkInterface.GetAllNetworkInterfaces())
            {
                if (string.Equals(nic.Name, displayName, StringComparison.OrdinalIgnoreCase) ||
                    string.Equals(nic.Description, displayName, StringComparison.OrdinalIgnoreCase))
                {
                    try { return nic.GetIPProperties().GetIPv4Properties().Index; }
                    catch (NetworkInformationException) { }
                }
            }
            // Fall back to auto-detect if name not matched
            return FindFirstEthernetAdapter().index;  // .name ignored in fallback
        }

        // --- Random string generator ---
        // Alphabet length is 32 (2^5), byte range is 256 (2^8) — no modulo bias.
        private static string GenerateRandomString(int length)
        {
            var buf = new byte[length];
            using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(buf);
            var sb = new System.Text.StringBuilder(length);
            for (int i = 0; i < length; i++)
                sb.Append(Proto.Alphabet[buf[i] % Proto.Alphabet.Length]);
            return sb.ToString();
        }

        private void Log(string msg) => _log?.Invoke(msg);

        public void Dispose()
        {
            _running = false;
            try { _replySocket?.Close(); } catch { }
            _plasma?.Dispose();
        }
    }
}
