using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Text.Json;
using Mbproxy.Admin;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Configuration.Memory;
using NModbus;
using Serilog;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Admin;

/// <summary>
/// End-to-end HTTP-level tests for the admin endpoint.
/// Each test starts an in-process host with a live Kestrel admin server and verifies
/// the shape and content of the responses.
///
/// Tests that require a Modbus simulator skip gracefully when Python / pymodbus
/// is not available.
/// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
public sealed class AdminEndpointTests
{
    private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
    private static readonly HttpClient HttpClient = new();

    public AdminEndpointTests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim)
    {
        _sim = sim;
    }

    // ── 1. GET /status.json returns valid JSON with expected top-level shape ──

    [Fact(Timeout = 5_000)]
    public async Task Get_StatusJson_ReturnsValidShape()
    {
        int adminPort  = PickFreePort();
        int proxyPort  = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
            proxyPort: proxyPort, bcd16Addresses: []);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);

        var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/status.json",
            TestContext.Current.CancellationToken);
        response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
        response.Content.Headers.ContentType?.MediaType.ShouldBe("application/json");

        string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
        var doc = JsonDocument.Parse(body);
        var root = doc.RootElement;

        // service sub-object
        root.TryGetProperty("service", out var svc).ShouldBeTrue("Missing 'service' field");
        svc.TryGetProperty("uptimeSeconds", out var svcUptime).ShouldBeTrue("Missing service.uptimeSeconds");
        svc.TryGetProperty("version", out var svcVersion).ShouldBeTrue("Missing service.version");
        svc.TryGetProperty("configReloadCount", out var svcReload).ShouldBeTrue("Missing service.configReloadCount");

        // listeners sub-object
        root.TryGetProperty("listeners", out var lst).ShouldBeTrue("Missing 'listeners' field");
        lst.TryGetProperty("bound", out var lstBound).ShouldBeTrue("Missing listeners.bound");
        lst.TryGetProperty("configured", out var lstConfigured).ShouldBeTrue("Missing listeners.configured");

        // plcs array
        root.TryGetProperty("plcs", out var plcs).ShouldBeTrue("Missing 'plcs' field");
        plcs.ValueKind.ShouldBe(JsonValueKind.Array);

        // per-plc shape (only if PLCs configured)
        if (plcs.GetArrayLength() > 0)
        {
            var plc0 = plcs[0];
            plc0.TryGetProperty("name", out var plcName).ShouldBeTrue("Missing plc.name");
            plc0.TryGetProperty("listener", out var listener).ShouldBeTrue("Missing plc.listener");
            listener.TryGetProperty("state", out var listenerState).ShouldBeTrue("Missing plc.listener.state");
            plc0.TryGetProperty("clients", out var clients).ShouldBeTrue("Missing plc.clients");
            clients.TryGetProperty("connected", out var clientsConn).ShouldBeTrue("Missing plc.clients.connected");
            clients.TryGetProperty("remoteEndpoints", out var clientsRemote).ShouldBeTrue("Missing plc.clients.remoteEndpoints");
            plc0.TryGetProperty("pdus", out var pdus).ShouldBeTrue("Missing plc.pdus");
            pdus.TryGetProperty("forwarded", out var pdusForwarded).ShouldBeTrue("Missing plc.pdus.forwarded");
            pdus.TryGetProperty("byFc", out var pdusByFc).ShouldBeTrue("Missing plc.pdus.byFc");
            plc0.TryGetProperty("backend", out var backend).ShouldBeTrue("Missing plc.backend");
            backend.TryGetProperty("lastRoundTripMs", out var backendRtt).ShouldBeTrue("Missing plc.backend.lastRoundTripMs");
            plc0.TryGetProperty("bytes", out var bytes).ShouldBeTrue("Missing plc.bytes");
            bytes.TryGetProperty("upstreamIn", out var bytesIn).ShouldBeTrue("Missing plc.bytes.upstreamIn");
        }
    }

    // ── 2. PDU count increases after FC03 read ────────────────────────────────

    [Fact(Timeout = 5_000)]
    public async Task Get_StatusJson_AfterReadFC03_ShowsPduCountIncreased()
    {
        if (_sim.SkipReason is not null)
            Assert.Skip(_sim.SkipReason);

        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: _sim.Host, simPort: _sim.Port,
            proxyPort: proxyPort, bcd16Addresses: [1072]);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);
        await WaitForListenerAsync(proxyPort);

        // Read baseline PDU count.
        long before = await GetPduForwardedAsync(adminPort);

        // Perform one FC03 read through the proxy.
        using var client = new TcpClient();
        await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
        var master = new ModbusFactory().CreateMaster(client);
        master.ReadHoldingRegisters(1, 1072, 1);

        // Give counters time to propagate.
        await Task.Delay(50, TestContext.Current.CancellationToken);

        long after = await GetPduForwardedAsync(adminPort);
        after.ShouldBeGreaterThan(before, "PDU count should increase after an FC03 read");
    }

    // ── 3. Partial BCD warning appears after partial overlap read ────────────

    [Fact(Timeout = 5_000)]
    public async Task Get_StatusJson_AfterPartialBcdWrite_ShowsPartialBcdWarning()
    {
        if (_sim.SkipReason is not null)
            Assert.Skip(_sim.SkipReason);

        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        // Configure a 32-bit BCD tag at 1072/1073.
        var host = BuildHost(adminPort: adminPort, simHost: _sim.Host, simPort: _sim.Port,
            proxyPort: proxyPort, bcd32Addresses: [1072]);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);
        await WaitForListenerAsync(proxyPort);

        // Read baseline partial BCD warning count.
        long before = await GetPartialBcdWarningsAsync(adminPort);

        // Read only the HIGH register (1073) of the 32-bit pair → partial overlap.
        using var client = new TcpClient();
        await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
        var master = new ModbusFactory().CreateMaster(client);
        master.ReadHoldingRegisters(1, 1073, 1); // partial overlap

        await Task.Delay(50, TestContext.Current.CancellationToken);

        long after = await GetPartialBcdWarningsAsync(adminPort);
        after.ShouldBeGreaterThan(before, "partialBcdWarnings should increment after partial overlap read");
    }

    // ── 4. GET / and GET /plc/{name} serve the embedded SPA shells ───────────

    [Fact(Timeout = 5_000)]
    public async Task Get_Root_ReturnsDashboardShell()
    {
        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
            proxyPort: proxyPort, bcd16Addresses: []);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);

        var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/",
            TestContext.Current.CancellationToken);
        response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
        response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");

        string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
        body.ShouldContain("<!doctype html>");
        body.ShouldContain("/assets/dashboard.js");
    }

    [Fact(Timeout = 5_000)]
    public async Task Get_PlcDetailRoute_ReturnsDetailShell()
    {
        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
            proxyPort: proxyPort, bcd16Addresses: []);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);

        var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/plc/anything",
            TestContext.Current.CancellationToken);
        response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
        response.Content.Headers.ContentType?.MediaType.ShouldBe("text/html");

        string body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
        body.ShouldContain("/assets/detail.js");
    }

    [Theory(Timeout = 5_000)]
    [InlineData("bootstrap.min.css", "text/css")]
    [InlineData("signalr.min.js", "text/javascript")]
    [InlineData("dashboard.js", "text/javascript")]
    [InlineData("theme.css", "text/css")]
    [InlineData("ibm-plex-mono-500.woff2", "font/woff2")]
    public async Task Get_Asset_ReturnsCorrectContentType(string file, string expectedType)
    {
        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
            proxyPort: proxyPort, bcd16Addresses: []);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);

        var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/{file}",
            TestContext.Current.CancellationToken);
        response.StatusCode.ShouldBe(System.Net.HttpStatusCode.OK);
        response.Content.Headers.ContentType?.MediaType.ShouldBe(expectedType);
        response.Headers.CacheControl?.ToString().ShouldContain("immutable");

        var bytes = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
        bytes.Length.ShouldBeGreaterThan(0);
    }

    [Fact(Timeout = 5_000)]
    public async Task Get_UnknownAsset_Returns404()
    {
        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
            proxyPort: proxyPort, bcd16Addresses: []);
        await using var _ = new AsyncHostDispose(host);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);

        await WaitForAdminAsync(adminPort);

        var response = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/assets/no-such-file.js",
            TestContext.Current.CancellationToken);
        response.StatusCode.ShouldBe(System.Net.HttpStatusCode.NotFound);
    }

    // ── 5. AdminPort collision → proxy still runs + bind.failed logged ────────

    [Fact(Timeout = 5_000)]
    public async Task AdminPort_BindFailure_ServiceStaysUp_AndLogsBindFailed()
    {
        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        // Occupy the admin port on ANY with exclusive use so the proxy Kestrel cannot bind it.
        var occupier = new TcpListener(IPAddress.Any, adminPort);
        occupier.Server.SetSocketOption(
            SocketOptionLevel.Socket,
            SocketOptionName.ExclusiveAddressUse,
            true);
        occupier.Start();

        try
        {
            var logSink = new CapturingSink();
            var serilog = new LoggerConfiguration()
                .MinimumLevel.Error()
                .WriteTo.Sink(logSink)
                .CreateLogger();

            var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
                proxyPort: proxyPort, bcd16Addresses: [], serilogOverride: serilog);
            await using var _ = new AsyncHostDispose(host);
            using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

            // StartAsync should NOT throw even though the admin port is taken.
            await host.StartAsync(startCts.Token);

            // Give the service time to attempt the bind.
            await Task.Delay(500, TestContext.Current.CancellationToken);

            // The Modbus proxy listener should still be up.
            bool proxyUp = CanConnect(proxyPort);
            proxyUp.ShouldBeTrue("Proxy listener should still be reachable despite admin bind failure");

            // The bind-failed event should have been logged.
            bool logged = logSink.Events.Any(e =>
                e.MessageTemplate.Text.Contains("mbproxy.admin.bind.failed") ||
                e.MessageTemplate.Text.Contains("Admin endpoint bind failed"));
            logged.ShouldBeTrue("mbproxy.admin.bind.failed should be logged when the admin port is in use");
        }
        finally
        {
            occupier.Stop();
        }
    }

    // ── 6. AdminPort hot-reload → server re-binds to new port ────────────────

    [Fact(Timeout = 5_000)]
    public async Task AdminPort_HotReload_RebindsToNewPort()
    {
        int adminPort1  = PickFreePort();
        int adminPort2  = PickFreePort();
        int proxyPort   = PickFreePort();

        // Write initial config to a temp file.
        string configPath = System.IO.Path.Combine(
            System.IO.Path.GetTempPath(),
            $"mbproxy_admin_hotreload_{Guid.NewGuid():N}.json");

        try
        {
            WriteConfig(configPath, adminPort: adminPort1, proxyPort: proxyPort);

            var logger = new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();
            var builder = Host.CreateApplicationBuilder();
            builder.Configuration.Sources.Clear();
            builder.Configuration.AddJsonFile(configPath, optional: false, reloadOnChange: true);
            builder.Services.AddSerilog(logger, dispose: false);
            builder.AddMbproxyOptions();
            builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
            builder.Services.AddSingleton<ProxyWorker>();
            builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
            builder.AddMbproxyAdmin();

            using var host = builder.Build();
            using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
            await host.StartAsync(startCts.Token);

            await WaitForAdminAsync(adminPort1);

            // Mutate the config file to change AdminPort.
            WriteConfig(configPath, adminPort: adminPort2, proxyPort: proxyPort);

            // Wait for admin endpoint to re-bind on new port.
            await WaitForAdminAsync(adminPort2);

            // Old port should no longer serve requests.
            bool oldPortStillUp;
            try
            {
                var r = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort1}/status.json",
                    new CancellationTokenSource(TimeSpan.FromSeconds(1)).Token);
                oldPortStillUp = r.IsSuccessStatusCode;
            }
            catch
            {
                oldPortStillUp = false;
            }

            oldPortStillUp.ShouldBeFalse("Old admin port should no longer be active after hot-reload");

            using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            await host.StopAsync(stopCts.Token);
        }
        finally
        {
            try { System.IO.File.Delete(configPath); } catch { }
        }
    }

    private static void WriteConfig(string path, int adminPort, int proxyPort)
    {
        var doc = new
        {
            Mbproxy = new
            {
                AdminPort  = adminPort,
                BcdTags    = new { Global = Array.Empty<object>() },
                Plcs       = new[] { new { Name = "PLC-A", ListenPort = proxyPort, Host = "127.0.0.1", Port = 502 } },
                Connection = new { BackendConnectTimeoutMs = 500, BackendRequestTimeoutMs = 500 },
            },
        };

        string tmp = path + ".tmp";
        System.IO.File.WriteAllText(tmp,
            System.Text.Json.JsonSerializer.Serialize(doc,
                new System.Text.Json.JsonSerializerOptions { WriteIndented = true }));
        System.IO.File.Move(tmp, path, overwrite: true);
    }

    // ── non-GET methods rejected ─────────────────────────────────────────

    /// <summary>
    /// Verifies the admin endpoint rejects non-GET methods (POST / PUT / DELETE)
    /// against the read-only routes `GET /` and `GET /status.json` with HTTP 405.
    /// (The SignalR hub at `/hub/status` legitimately accepts POST and is not tested
    /// here.) Guards against an accidental MapPost/Map* being added later.
    /// </summary>
    [Theory(Timeout = 5_000)]
    [InlineData("POST")]
    [InlineData("PUT")]
    [InlineData("DELETE")]
    [InlineData("PATCH")]
    public async Task NonGetMethod_AgainstAdminRoutes_Returns405(string method)
    {
        int adminPort = PickFreePort();
        int proxyPort = PickFreePort();

        var host = BuildHost(adminPort: adminPort, simHost: "127.0.0.1", simPort: 502,
            proxyPort: proxyPort, bcd16Addresses: []);
        await using var _ = new AsyncHostDispose(host);
        await host.StartAsync(TestContext.Current.CancellationToken);
        await WaitForAdminAsync(adminPort);

        foreach (string path in new[] { "/", "/status.json" })
        {
            using var req = new HttpRequestMessage(new HttpMethod(method),
                $"http://127.0.0.1:{adminPort}{path}");
            using var resp = await HttpClient.SendAsync(req, TestContext.Current.CancellationToken);
            resp.StatusCode.ShouldBe(HttpStatusCode.MethodNotAllowed,
                $"{method} {path} must be rejected (admin endpoint is read-only)");
        }
    }

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

    private static IHost BuildHost(
        int adminPort,
        string simHost,
        int simPort,
        int proxyPort,
        ushort[]? bcd16Addresses = null,
        ushort[]? bcd32Addresses = null,
        Serilog.ILogger? serilogOverride = null)
    {
        var config = new Dictionary<string, string?>
        {
            ["Mbproxy:AdminPort"]                              = adminPort.ToString(),
            ["Mbproxy:Plcs:0:Name"]                           = "TestPLC",
            ["Mbproxy:Plcs:0:ListenPort"]                     = proxyPort.ToString(),
            ["Mbproxy:Plcs:0:Host"]                           = simHost,
            ["Mbproxy:Plcs:0:Port"]                           = simPort.ToString(),
            ["Mbproxy:Connection:BackendConnectTimeoutMs"]     = "3000",
            ["Mbproxy:Connection:BackendRequestTimeoutMs"]     = "3000",
        };

        int tagIndex = 0;
        foreach (ushort addr in bcd16Addresses ?? [])
        {
            config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
            config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"]   = "16";
            tagIndex++;
        }
        foreach (ushort addr in bcd32Addresses ?? [])
        {
            config[$"Mbproxy:BcdTags:Global:{tagIndex}:Address"] = addr.ToString();
            config[$"Mbproxy:BcdTags:Global:{tagIndex}:Width"]   = "32";
            tagIndex++;
        }

        var logger = serilogOverride
            ?? new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger();

        var builder = Host.CreateApplicationBuilder();
        builder.Configuration.AddInMemoryCollection(config);
        builder.Services.AddSerilog(logger, dispose: false);
        builder.AddMbproxyOptions();
        builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
        // Register as singleton so StatusSnapshotBuilder can inject ProxyWorker directly.
        builder.Services.AddSingleton<ProxyWorker>();
        builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());
        builder.AddMbproxyAdmin();

        return builder.Build();
    }

    private static async Task WaitForAdminAsync(int adminPort)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        while (!cts.IsCancellationRequested)
        {
            try
            {
                var r = await HttpClient.GetAsync($"http://127.0.0.1:{adminPort}/status.json", cts.Token);
                if (r.StatusCode == System.Net.HttpStatusCode.OK) return;
            }
            catch { }
            await Task.Delay(100, cts.Token).ConfigureAwait(false);
        }
        throw new TimeoutException($"Admin endpoint on port {adminPort} did not start in time.");
    }

    private static async Task WaitForListenerAsync(int proxyPort)
    {
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        while (!cts.IsCancellationRequested)
        {
            if (CanConnect(proxyPort)) return;
            await Task.Delay(50, cts.Token).ConfigureAwait(false);
        }
        throw new TimeoutException($"Proxy listener on port {proxyPort} did not start in time.");
    }

    private static async Task<long> GetPduForwardedAsync(int adminPort)
    {
        string body = await HttpClient.GetStringAsync($"http://127.0.0.1:{adminPort}/status.json");
        var doc  = JsonDocument.Parse(body);
        var plcs = doc.RootElement.GetProperty("plcs");
        if (plcs.GetArrayLength() == 0) return 0;
        return plcs[0].GetProperty("pdus").GetProperty("forwarded").GetInt64();
    }

    private static async Task<long> GetPartialBcdWarningsAsync(int adminPort)
    {
        string body = await HttpClient.GetStringAsync($"http://127.0.0.1:{adminPort}/status.json");
        var doc  = JsonDocument.Parse(body);
        var plcs = doc.RootElement.GetProperty("plcs");
        if (plcs.GetArrayLength() == 0) return 0;
        return plcs[0].GetProperty("pdus").GetProperty("partialBcdWarnings").GetInt64();
    }

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

    private static bool CanConnect(int port)
    {
        try { using var c = new TcpClient(); c.Connect("127.0.0.1", port); return true; }
        catch { return false; }
    }

    private sealed class AsyncHostDispose : IAsyncDisposable
    {
        private readonly IHost _host;
        public AsyncHostDispose(IHost host) => _host = host;
        public async ValueTask DisposeAsync()
        {
            using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            try { await _host.StopAsync(cts.Token); } catch { }
            _host.Dispose();
        }
    }

    private sealed class CapturingSink : Serilog.Core.ILogEventSink
    {
        private readonly System.Collections.Concurrent.ConcurrentQueue<Serilog.Events.LogEvent> _q = new();
        public System.Collections.Generic.IEnumerable<Serilog.Events.LogEvent> Events => _q;
        public void Emit(Serilog.Events.LogEvent e) => _q.Enqueue(e);
    }
}
