using System;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using Tesla.Net;
using VPod;
using Xunit;

namespace TeslaConsole.DiffTests
{
    /// <summary>
    /// End-to-end loopback exercise of vPOD's virtual launcher: the REAL
    /// console-side client (PodManagerConnection) against vPOD's LauncherRpcServer
    /// on an ephemeral port. Covers the OFB/CONF handshake, the ILauncherService
    /// dispatch surface, and the out-of-band InstallProduct transfer — including
    /// the 99%-complete convention the console's install retry loop depends on.
    /// This validates both ends: the client the console ships and the server vPOD
    /// uses to stand in for a pod's TeslaLauncher service.
    /// </summary>
    public class VPodLauncherServerTests : IDisposable
    {
        private readonly string mDataDir;
        private readonly VirtualLauncher mLauncher;
        private readonly LauncherRpcServer mServer;
        private readonly PodManagerConnection mClient;
        private readonly byte[] mKey;
        private readonly int mPort;

        public VPodLauncherServerTests()
        {
            mDataDir = Path.Combine(Path.GetTempPath(), "vpod-test-" + Guid.NewGuid().ToString("N"));
            // Isolated games root: vPOD's default is the real C:\Games (launcher parity).
            mLauncher = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games"));
            mKey = new byte[32];
            new Random(1234).NextBytes(mKey);
            mPort = GetFreePort();
            mServer = new LauncherRpcServer(mLauncher, mPort);
            mServer.Start(mKey);
            mClient = new PodManagerConnection();
            mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey);
        }

        public void Dispose()
        {
            try { mClient.Close(); } catch { }
            mServer.Stop();
            try { Directory.Delete(mDataDir, recursive: true); } catch { }
        }

        [Fact]
        public void Handshake_And_Ping_RoundTrip()
        {
            Assert.True(mClient.IsOpen);
            var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
            Assert.Equal(now, mClient.Ping(now));
        }

        [Fact]
        public void Handshake_With_Wrong_Key_Is_Rejected()
        {
            var wrongKey = new byte[32];
            new Random(9999).NextBytes(wrongKey);
            using (var badClient = new PodManagerConnection())
            {
                Assert.Throws<IOException>(
                    () => badClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), wrongKey));
            }
        }

        [Fact]
        public void InstallApp_Appears_In_GetInstalledApps_And_FullUpdate()
        {
            var app = SampleApp("Red Planet 4.11");
            mClient.InstallApp(app);

            var installed = mClient.GetInstalledApps();
            Assert.Single(installed);
            Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
            Assert.Equal(app.ExeFile, installed[0].ExeFile);
            Assert.Equal(app.Arguments, installed[0].Arguments);
            Assert.True(installed[0].AutoRestart);

            var launchable = mClient.GetLaunchableApps();
            Assert.Single(launchable);
            Assert.Equal("Red Planet 4.11", launchable[0].DisplayName);

            var full = mClient.FullUpdate();
            Assert.Single(full.InstalledApps);
            Assert.Empty(full.LaunchedApps);
        }

        [Fact]
        public void LaunchApp_Simulates_Pids_And_Kill_Removes_Them()
        {
            var app = SampleApp("BattleTech 4.11");
            mClient.InstallApp(app);

            int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
            Assert.True(pid > 0);
            var launched = mClient.GetLaunchedApps();
            Assert.Single(launched);
            Assert.Equal(pid, launched[0].ProcessId);
            Assert.Equal(app.LaunchPair.LaunchKey, launched[0].LaunchKey);

            mClient.KillApp(app.LaunchPair.LaunchKey, pid);
            Assert.Empty(mClient.GetLaunchedApps());

            mClient.LaunchApp(app.LaunchPair.LaunchKey);
            mClient.LaunchApp(app.LaunchPair.LaunchKey);
            mClient.KillAllApps();
            Assert.Empty(mClient.GetLaunchedApps());
        }

        [Fact]
        public void VolumeLevel_RoundTrips()
        {
            mClient.VolumeLevel = 0.25f;
            Assert.Equal(0.25f, mClient.VolumeLevel);
            Assert.Equal(0.25f, mClient.FullUpdate().VolumeLevel);
        }

        [Fact]
        public void InstallProduct_Streams_Extracts_And_Completes_At_99()
        {
            string zipPath = MakeTestZip("TestGame", "readme.txt", "hello vpod");

            Guid callId = mClient.InstallProduct(zipPath);
            Assert.NotEqual(Guid.Empty, callId);

            var progress = PollUntilCompleted(callId);
            // 99, not 100: the console's InstallProductWorker only breaks its
            // 3-attempt retry loop on exactly 99.
            Assert.Equal(99, progress.PercentComplete);
            Assert.Equal("Complete", progress.Status);

            string extracted = Path.Combine(mDataDir, "Games", "TestGame", "readme.txt");
            Assert.True(File.Exists(extracted), "expected extracted file at " + extracted);
            Assert.Equal("hello vpod", File.ReadAllText(extracted));

            // The main connection survived the concurrent out-of-band transfer.
            Assert.True(mClient.IsOpen);
            var now = DateTime.UtcNow;
            mClient.Ping(now);
        }

        [Fact]
        public void UninstallApp_Removes_Registration_And_Product_Directory()
        {
            string zipPath = MakeTestZip("TestGame", "game.exe", "not really an exe");
            Guid callId = mClient.InstallProduct(zipPath);
            PollUntilCompleted(callId);

            string productDir = Path.Combine(mDataDir, "Games", "TestGame");
            var app = new LaunchData
            {
                LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Test Game" },
                WorkingDirectory = productDir,
                ExeFile = Path.Combine(productDir, "game.exe"),
                Arguments = "",
                AutoRestart = false
            };
            mClient.InstallApp(app);
            Assert.Single(mClient.GetInstalledApps());
            Assert.True(Directory.Exists(productDir));

            mClient.UninstallApp(app.LaunchPair.LaunchKey);
            Assert.Empty(mClient.GetInstalledApps());
            Assert.False(Directory.Exists(productDir), "product directory should be cleaned up");
        }

        [Fact]
        public void Shutdown_Raises_ShutdownRequested()
        {
            bool? restartArg = null;
            using (var signalled = new ManualResetEventSlim())
            {
                mLauncher.ShutdownRequested += restart =>
                {
                    restartArg = restart;
                    signalled.Set();
                };
                mClient.Shutdown(doRestart: true);
                Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ShutdownRequested was not raised");
                Assert.True(restartArg);
            }
        }

        [Fact]
        public void ClearStore_Wipes_Apps_And_Requests_Reprovisioning()
        {
            mClient.InstallApp(SampleApp("Doomed App"));
            using (var signalled = new ManualResetEventSlim())
            {
                mLauncher.ReprovisionRequested += signalled.Set;
                mClient.ClearStore(); // one-way: no response frame
                Assert.True(signalled.Wait(TimeSpan.FromSeconds(5)), "ReprovisionRequested was not raised");
            }
            Assert.Empty(mLauncher.GetInstalledApps());
        }

        [Fact]
        public void RealLaunch_Starts_And_Kills_Real_Processes()
        {
            mLauncher.RealLaunch = true;
            var app = new LaunchData
            {
                LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Real Pinger" },
                WorkingDirectory = Environment.SystemDirectory,
                ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"),
                Arguments = "-n 60 127.0.0.1",
                AutoRestart = false
            };
            mClient.InstallApp(app);

            int pid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
            Assert.True(pid > 0);
            using (var real = Process.GetProcessById(pid)) // throws if not actually running
            {
                Assert.False(real.HasExited);
                var launched = mClient.GetLaunchedApps();
                Assert.Single(launched);
                Assert.Equal(pid, launched[0].ProcessId);

                mClient.KillAllOfType(app.LaunchPair.LaunchKey);
                Assert.True(real.WaitForExit(5000), "real process was not terminated");
            }
            Assert.Empty(mClient.GetLaunchedApps());
        }

        [Fact]
        public void RealLaunch_Watchdog_Restarts_AutoRestart_Apps_That_Exit_On_Their_Own()
        {
            mLauncher.RealLaunch = true;
            mLauncher.RealAutoRestart = true;
            var app = new LaunchData
            {
                LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = "Short Pinger" },
                WorkingDirectory = Environment.SystemDirectory,
                ExeFile = Path.Combine(Environment.SystemDirectory, "ping.exe"),
                Arguments = "-n 3 127.0.0.1", // exits on its own after ~2 s
                AutoRestart = true
            };
            mClient.InstallApp(app);
            int firstPid = mClient.LaunchApp(app.LaunchPair.LaunchKey);
            Assert.True(firstPid > 0);

            // The app exits by itself; the watchdog must bring up a NEW pid
            // (exit ~2 s + the Agent's 2 s restart delay).
            int restartedPid = 0;
            var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(20);
            while (DateTime.UtcNow < deadline)
            {
                var launched = mClient.GetLaunchedApps();
                if (launched.Length == 1 && launched[0].ProcessId != firstPid)
                {
                    restartedPid = launched[0].ProcessId;
                    break;
                }
                Thread.Sleep(100);
            }
            Assert.True(restartedPid > 0, "watchdog did not restart the exited app");

            // Turning the watchdog off ends the cycle: the current instance
            // exits on its own and nothing relaunches it.
            mLauncher.RealAutoRestart = false;
            deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
            while (DateTime.UtcNow < deadline && mClient.GetLaunchedApps().Length > 0)
            {
                Thread.Sleep(200);
            }
            Assert.Empty(mClient.GetLaunchedApps());
        }

        [Fact]
        public void RealLaunch_Missing_Exe_Surfaces_The_Agents_Error()
        {
            mLauncher.RealLaunch = true;
            var app = SampleApp("Not Installed Yet"); // ExeFile doesn't exist on disk
            mClient.InstallApp(app);

            var ex = Assert.ThrowsAny<Exception>(() => mClient.LaunchApp(app.LaunchPair.LaunchKey));
            Assert.Contains("executable not found", ex.Message);
            Assert.Empty(mClient.GetLaunchedApps());
        }

        [Fact]
        public void Dropped_Session_Still_Reports_Open_Until_Probed_Then_Reconnect_Works()
        {
            // The launcher (and vPOD) drops sessions idle >30s. Simulate the drop
            // server-side and pin the premise PodInfo.EnsureConnectionAlive relies
            // on: the client socket still REPORTS open until an I/O fails...
            mServer.Stop();
            mServer.Start(mKey);
            Thread.Sleep(100); // let the FIN arrive
            Assert.True(mClient.IsOpen, "a dropped-but-unused connection should still report open (the stale state)");

            // ...the cheap Ping probe is what exposes the dead connection...
            Assert.ThrowsAny<Exception>(() => mClient.Ping(DateTime.UtcNow));

            // ...and close + reopen (EnsureConnectionAlive's recovery) restores service.
            mClient.Close();
            mClient.Open(new IPEndPoint(IPAddress.Loopback, mPort), mKey);
            var now = new DateTime(2026, 7, 9, 12, 0, 0, DateTimeKind.Utc);
            Assert.Equal(now, mClient.Ping(now));
        }

        [Fact]
        public void Installed_Apps_Persist_Across_Launcher_Restart()
        {
            var app = SampleApp("Persistent App");
            mClient.InstallApp(app);

            var reloaded = new VirtualLauncher(mDataDir, Path.Combine(mDataDir, "Games"));
            var installed = reloaded.GetInstalledApps();
            Assert.Single(installed);
            Assert.Equal(app.LaunchPair.LaunchKey, installed[0].LaunchPair.LaunchKey);
            Assert.Equal("Persistent App", installed[0].LaunchPair.DisplayName);
        }

        // ---- helpers ----

        private static LaunchData SampleApp(string name)
        {
            return new LaunchData
            {
                LaunchPair = new LaunchPair { LaunchKey = Guid.NewGuid(), DisplayName = name },
                WorkingDirectory = @"C:\Games\Sample",
                ExeFile = @"C:\Games\Sample\game.exe",
                Arguments = "-net 1501 -res 1920 1080",
                AutoRestart = true
            };
        }

        private string MakeTestZip(string folder, string fileName, string content)
        {
            string zipPath = Path.Combine(mDataDir, "product-" + Guid.NewGuid().ToString("N") + ".zip");
            using (var fs = File.Create(zipPath))
            using (var zip = new ZipArchive(fs, ZipArchiveMode.Create))
            {
                var entry = zip.CreateEntry(folder + "/" + fileName);
                using (var writer = new StreamWriter(entry.Open()))
                {
                    writer.Write(content);
                }
            }
            return zipPath;
        }

        private OutOfBandProgress PollUntilCompleted(Guid callId)
        {
            var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(15);
            while (DateTime.UtcNow < deadline)
            {
                var progress = mClient.GetOutOfBandProgress(callId);
                if (progress.IsCompleted)
                {
                    return progress;
                }
                Thread.Sleep(50);
            }
            throw new TimeoutException("Install did not complete in time.");
        }

        private static int GetFreePort()
        {
            var listener = new TcpListener(IPAddress.Loopback, 0);
            listener.Start();
            int port = ((IPEndPoint)listener.LocalEndpoint).Port;
            listener.Stop();
            return port;
        }
    }
}
