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

namespace Mbproxy.Tests.Proxy.Cache;

/// <summary>
/// End-to-end coverage of the response cache against the pymodbus DL205 simulator.
///
/// <para><b>pymodbus 3.13 simulator quirk.</b> These tests serialise reads in the
/// simulator-backed cases. The cache's behavioural guarantee (a TTL-bounded cache hit
/// returns the cached value without backend traffic) is independent of the simulator's
/// known concurrent-MBAP-frame bug — sequential reads keep the sim in single-PDU mode,
/// which is its known-good envelope.</para>
///
/// <para>The headline assertion lives here: 10 reads at 100 ms intervals with a 1 s TTL
/// must result in EXACTLY 1 backend round-trip.</para>
/// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
public sealed class ResponseCacheE2ETests
{
    private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;
    public ResponseCacheE2ETests(Mbproxy.Tests.Sim.DL205SimulatorFixture sim) => _sim = sim;

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

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

    private Dictionary<string, string?> MakeBaseConfig(int proxyPort) => new()
    {
        ["Mbproxy:AdminPort"] = "0",
        [$"Mbproxy:Plcs:0:Name"]       = "TestPLC",
        [$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
        [$"Mbproxy:Plcs:0:Host"]       = _sim.Host,
        [$"Mbproxy:Plcs:0:Port"]       = _sim.Port.ToString(),
        ["Mbproxy:Connection:BackendConnectTimeoutMs"] = "3000",
        ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
    };

    private static IHost BuildBcdHost(Dictionary<string, string?> config)
    {
        var builder = Host.CreateApplicationBuilder();
        builder.Configuration.AddInMemoryCollection(config);
        builder.Services.AddSerilog(
            new LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
            dispose: false);
        builder.AddMbproxyOptions();
        builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
        builder.Services.AddSingleton<ProxyWorker>();
        builder.Services.AddHostedService(sp => sp.GetRequiredService<ProxyWorker>());

        if (int.TryParse(config["Mbproxy:AdminPort"], out int admin) && admin > 0)
            builder.AddMbproxyAdmin();
        return builder.Build();
    }

    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(2));
            try { await _host.StopAsync(cts.Token); } catch { }
            _host.Dispose();
        }
    }

    // ── Headline test: 10 reads at 100 ms intervals → exactly 1 backend round-trip ──

    /// <summary>
    /// The "is the design pivot worth it?" test. Configure a BCD tag with <c>CacheTtlMs =
    /// 1000</c>; issue 10 reads at 100 ms intervals through the proxy. The cache HitCount
    /// must show 9 (one miss to prime, 9 hits to serve) and the backend trip count must
    /// be exactly 1.
    /// </summary>
    [Fact(Timeout = 5_000)]
    public async Task E2E_CacheHit_TenReadsIn1Sec_BackendSeesOneRoundTrip()
    {
        if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);

        int proxyPort = PickFreePort();
        int adminPort = PickFreePort();
        var config = MakeBaseConfig(proxyPort);
        config["Mbproxy:AdminPort"] = adminPort.ToString();
        config["Mbproxy:BcdTags:Global:0:Address"]    = "1072";
        config["Mbproxy:BcdTags:Global:0:Width"]      = "16";
        config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "1000";

        var host = BuildBcdHost(config);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        await host.StartAsync(startCts.Token);
        await using var hd = new AsyncHostDispose(host);
        await Task.Delay(300, TestContext.Current.CancellationToken);

        using (var client = new TcpClient())
        {
            await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
            var master = new ModbusFactory().CreateMaster(client);

            // 10 reads at 100 ms intervals — total elapsed ~900 ms, well within the 1000 ms TTL.
            for (int i = 0; i < 10; i++)
            {
                ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
                regs[0].ShouldBe((ushort)1234, $"read #{i}: BCD-decoded value must be 1234");
                if (i < 9)
                    await Task.Delay(100, TestContext.Current.CancellationToken);
            }
        }

        using var httpClient = new HttpClient();
        var resp = await httpClient.GetStringAsync(
            $"http://127.0.0.1:{adminPort}/status.json",
            TestContext.Current.CancellationToken);

        using var doc = JsonDocument.Parse(resp);
        var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");

        // 9 cache hits (the first read populated the cache; the next 9 returned from cache).
        // 1 cache miss (the priming read).
        backend.GetProperty("cacheHitCount").GetInt64()
            .ShouldBe(9, "10 reads with TTL=1000 ms at 100 ms intervals must produce 9 cache hits");
        backend.GetProperty("cacheMissCount").GetInt64()
            .ShouldBe(1, "exactly the first read should miss");

        // The backend-trip count is observable via coalescedMissCount (every read that
        // makes it to the backend increments this counter; cache hits short-circuit).
        backend.GetProperty("coalescedMissCount").GetInt64()
            .ShouldBe(1, "exactly one read must reach the backend");
    }

    // ── Regression: cache disabled by default ────────────────────────────────

    /// <summary>
    /// Mandatory regression. With no cache config anywhere (default deployment shape),
    /// behaviour must be byte-identical to the non-cached path: sequential reads through
    /// the same client produce one backend round-trip each — no elision.
    /// </summary>
    [Fact(Timeout = 5_000)]
    public async Task Cache_DisabledByDefault_BehaviourIs_ByteIdenticalTo_Phase10()
    {
        if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);

        int proxyPort = PickFreePort();
        int adminPort = PickFreePort();
        var config = MakeBaseConfig(proxyPort);
        config["Mbproxy:AdminPort"] = adminPort.ToString();
        // No Cache section, no CacheTtlMs on any tag — non-cached behaviour.
        config["Mbproxy:BcdTags:Global:0:Address"] = "1072";
        config["Mbproxy:BcdTags:Global:0:Width"]   = "16";

        var host = BuildBcdHost(config);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        await host.StartAsync(startCts.Token);
        await using var hd = new AsyncHostDispose(host);
        await Task.Delay(300, TestContext.Current.CancellationToken);

        using (var client = new TcpClient())
        {
            await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
            var master = new ModbusFactory().CreateMaster(client);

            for (int i = 0; i < 5; i++)
            {
                ushort[] regs = master.ReadHoldingRegisters(1, 1072, 1);
                regs[0].ShouldBe((ushort)1234, $"read #{i} must still BCD-decode correctly");
            }
        }

        using var httpClient = new HttpClient();
        var resp = await httpClient.GetStringAsync(
            $"http://127.0.0.1:{adminPort}/status.json",
            TestContext.Current.CancellationToken);

        using var doc = JsonDocument.Parse(resp);
        var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");

        backend.GetProperty("cacheHitCount").GetInt64()
            .ShouldBe(0, "no cache config: HitCount must remain at zero");
        backend.GetProperty("cacheMissCount").GetInt64()
            .ShouldBe(0, "no cache config: MissCount must remain at zero — cache counters are tracked only for cache-eligible reads");
        backend.GetProperty("coalescedMissCount").GetInt64()
            .ShouldBe(5, "every read must reach the backend as before — Phase-10 behaviour preserved");
    }

    // ── TTL expiry path ─────────────────────────────────────────────────────

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

        int proxyPort = PickFreePort();
        int adminPort = PickFreePort();
        var config = MakeBaseConfig(proxyPort);
        config["Mbproxy:AdminPort"] = adminPort.ToString();
        config["Mbproxy:BcdTags:Global:0:Address"]    = "1072";
        config["Mbproxy:BcdTags:Global:0:Width"]      = "16";
        config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "200";

        var host = BuildBcdHost(config);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        await host.StartAsync(startCts.Token);
        await using var hd = new AsyncHostDispose(host);
        await Task.Delay(300, TestContext.Current.CancellationToken);

        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);   // miss, populates cache
            _ = master.ReadHoldingRegisters(1, 1072, 1);   // hit
            await Task.Delay(350, TestContext.Current.CancellationToken); // > TTL
            _ = master.ReadHoldingRegisters(1, 1072, 1);   // miss again
        }

        using var httpClient = new HttpClient();
        var resp = await httpClient.GetStringAsync(
            $"http://127.0.0.1:{adminPort}/status.json",
            TestContext.Current.CancellationToken);

        using var doc = JsonDocument.Parse(resp);
        var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");

        backend.GetProperty("cacheHitCount").GetInt64()
            .ShouldBe(1, "exactly one read should land inside the TTL window");
        backend.GetProperty("cacheMissCount").GetInt64()
            .ShouldBe(2, "two reads should miss (initial fill and post-expiry refill)");
        backend.GetProperty("coalescedMissCount").GetInt64()
            .ShouldBe(2, "the two cache misses must each produce a backend round-trip");
    }

    // ── Write invalidation ───────────────────────────────────────────────────

    /// <summary>
    /// Uses a register OUTSIDE the simulator's seeded BCD range so subsequent tests'
    /// reads of register 1072 are not polluted by this test's write. The simulator's
    /// holding-register table is shared across tests in the collection.
    /// </summary>
    [Fact(Timeout = 5_000)]
    public async Task E2E_WriteInvalidatesOverlappingCacheEntries()
    {
        if (_sim.SkipReason is not null) Assert.Skip(_sim.SkipReason);

        // Use a scratch register (200) from the simulator's allowed-write range so the
        // FC06 write does not fault on the simulator side, but a register that no other
        // test reads with BCD decoding — its initial value 0 round-trips through the
        // BCD codec without surprises, and any post-write state stays contained.
        const ushort isolatedRegister = 200;
        int proxyPort = PickFreePort();
        int adminPort = PickFreePort();
        var config = MakeBaseConfig(proxyPort);
        config["Mbproxy:AdminPort"] = adminPort.ToString();
        config["Mbproxy:BcdTags:Global:0:Address"]    = isolatedRegister.ToString();
        config["Mbproxy:BcdTags:Global:0:Width"]      = "16";
        config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";

        var host = BuildBcdHost(config);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        await host.StartAsync(startCts.Token);
        await using var hd = new AsyncHostDispose(host);
        await Task.Delay(300, TestContext.Current.CancellationToken);

        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, isolatedRegister, 1);   // miss → cached
            _ = master.ReadHoldingRegisters(1, isolatedRegister, 1);   // hit
            master.WriteSingleRegister(1, isolatedRegister, 4321);      // invalidates the cached entry
            _ = master.ReadHoldingRegisters(1, isolatedRegister, 1);   // must miss again
        }

        using var httpClient = new HttpClient();
        var resp = await httpClient.GetStringAsync(
            $"http://127.0.0.1:{adminPort}/status.json",
            TestContext.Current.CancellationToken);

        using var doc = JsonDocument.Parse(resp);
        var backend = doc.RootElement.GetProperty("plcs")[0].GetProperty("backend");

        backend.GetProperty("cacheHitCount").GetInt64()
            .ShouldBe(1, "the second read should hit the cache");
        backend.GetProperty("cacheMissCount").GetInt64()
            .ShouldBe(2, "first read primes the cache; third read misses because the write invalidated the entry");
        backend.GetProperty("cacheInvalidations").GetInt64()
            .ShouldBe(1, "the FC06 write must invalidate exactly one cache entry");
    }

    // ── BCD-decoded bytes are cached ─────────────────────────────────────────

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

        int proxyPort = PickFreePort();
        var config = MakeBaseConfig(proxyPort);
        config["Mbproxy:BcdTags:Global:0:Address"]    = "1072";
        config["Mbproxy:BcdTags:Global:0:Width"]      = "16";
        config["Mbproxy:BcdTags:Global:0:CacheTtlMs"] = "5000";

        var host = BuildBcdHost(config);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        await host.StartAsync(startCts.Token);
        await using var hd = new AsyncHostDispose(host);
        await Task.Delay(300, TestContext.Current.CancellationToken);

        using var client = new TcpClient();
        await client.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);
        var master = new ModbusFactory().CreateMaster(client);

        ushort[] r1 = master.ReadHoldingRegisters(1, 1072, 1);
        ushort[] r2 = master.ReadHoldingRegisters(1, 1072, 1);
        ushort[] r3 = master.ReadHoldingRegisters(1, 1072, 1);

        r1[0].ShouldBe((ushort)1234, "first read must be BCD-decoded");
        r2[0].ShouldBe((ushort)1234, "second read (cache hit) must return decoded 1234, not raw BCD 0x1234");
        r3[0].ShouldBe((ushort)1234, "third read (cache hit) must return decoded 1234");
    }
}
