using System.Collections.Concurrent;
using System.Collections.Frozen;
using System.Net;
using System.Net.Sockets;
using Mbproxy.Bcd;
using Mbproxy.Options;
using Mbproxy.Proxy;
using Mbproxy.Proxy.Cache;
using Mbproxy.Proxy.Multiplexing;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Proxy.Cache;

/// <summary>
/// Phase-11 cache wiring inside the multiplexer, exercised against a stub backend with
/// deterministic response timing. Stub-backend tests are the "is the cache wired correctly"
/// proof — they cover behaviour the simulator-backed E2E suite cannot exercise reliably
/// (true concurrent reads through the cache; cross-PLC isolation).
/// </summary>
[Trait("Category", "Unit")]
public sealed class ResponseCacheMultiplexerTests
{
    // ── Frame helpers ─────────────────────────────────────────────────────────────

    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 async Task<byte[]> ReadExactAsync(Socket s, int count, CancellationToken ct)
    {
        var buf = new byte[count];
        int read = 0;
        while (read < count)
        {
            int n = await s.ReceiveAsync(buf.AsMemory(read, count - read), SocketFlags.None, ct);
            if (n == 0) throw new IOException("EOF");
            read += n;
        }
        return buf;
    }

    private static async Task<byte[]> ReadOneFrameAsync(Socket s, CancellationToken ct)
    {
        var header = await ReadExactAsync(s, 7, ct);
        ushort length = (ushort)((header[4] << 8) | header[5]);
        int bodyLen = length - 1;
        var body = bodyLen > 0 ? await ReadExactAsync(s, bodyLen, ct) : Array.Empty<byte>();
        var frame = new byte[7 + bodyLen];
        Buffer.BlockCopy(header, 0, frame, 0, 7);
        if (bodyLen > 0) Buffer.BlockCopy(body, 0, frame, 7, bodyLen);
        return frame;
    }

    private static byte[] BuildFc03(ushort txId, ushort start, ushort qty, byte unit = 1)
        => [
            (byte)(txId >> 8), (byte)(txId & 0xFF),
            0x00, 0x00,
            0x00, 0x06,
            unit, 0x03,
            (byte)(start >> 8), (byte)(start & 0xFF),
            (byte)(qty >> 8),   (byte)(qty & 0xFF),
        ];

    private static byte[] BuildFc06(ushort txId, ushort addr, ushort value, byte unit = 1)
        => [
            (byte)(txId >> 8), (byte)(txId & 0xFF),
            0x00, 0x00,
            0x00, 0x06,
            unit, 0x06,
            (byte)(addr >> 8), (byte)(addr & 0xFF),
            (byte)(value >> 8), (byte)(value & 0xFF),
        ];

    /// <summary>
    /// Stub backend that responds immediately with a configurable register value. Records
    /// every backend request it receives so the test can count round-trips.
    /// </summary>
    private sealed class StubBackend : IAsyncDisposable
    {
        public int Port { get; }
        public int RequestCount => _requestCount;
        public ushort RegisterValue { get; set; } = 0x1234;

        private readonly TcpListener _listener;
        private readonly CancellationTokenSource _cts = new();
        private readonly List<Task> _tasks = new();
        private int _requestCount;

        public StubBackend(int port)
        {
            Port = port;
            _listener = new TcpListener(IPAddress.Loopback, port);
            _listener.Start();
            _ = AcceptLoop();
        }

        private async Task AcceptLoop()
        {
            try
            {
                while (!_cts.IsCancellationRequested)
                {
                    var s = await _listener.AcceptSocketAsync(_cts.Token);
                    var t = Task.Run(() => HandleAsync(s));
                    lock (_tasks) _tasks.Add(t);
                }
            }
            catch { }
        }

        private async Task HandleAsync(Socket s)
        {
            try
            {
                while (!_cts.IsCancellationRequested)
                {
                    var req = await ReadOneFrameAsync(s, _cts.Token);
                    if (req.Length < 8) break;
                    Interlocked.Increment(ref _requestCount);

                    ushort txId = (ushort)((req[0] << 8) | req[1]);
                    byte unit = req[6];
                    byte fc = req[7];

                    byte[] response;
                    if (fc == 0x03 || fc == 0x04)
                    {
                        ushort qty = (ushort)((req[10] << 8) | req[11]);
                        int byteCount = qty * 2;
                        response = new byte[7 + 2 + byteCount];
                        response[0] = (byte)(txId >> 8);
                        response[1] = (byte)(txId & 0xFF);
                        response[2] = 0; response[3] = 0;
                        ushort len = (ushort)(1 + 2 + byteCount);
                        response[4] = (byte)(len >> 8);
                        response[5] = (byte)(len & 0xFF);
                        response[6] = unit;
                        response[7] = fc;
                        response[8] = (byte)byteCount;
                        for (int i = 0; i < qty; i++)
                        {
                            response[9 + i * 2] = (byte)(RegisterValue >> 8);
                            response[9 + i * 2 + 1] = (byte)(RegisterValue & 0xFF);
                        }
                    }
                    else if (fc == 0x06)
                    {
                        ushort addr = (ushort)((req[8] << 8) | req[9]);
                        ushort val = (ushort)((req[10] << 8) | req[11]);
                        response = new byte[12];
                        response[0] = (byte)(txId >> 8);
                        response[1] = (byte)(txId & 0xFF);
                        response[2] = 0; response[3] = 0;
                        response[4] = 0; response[5] = 6;
                        response[6] = unit; response[7] = 0x06;
                        response[8] = (byte)(addr >> 8); response[9] = (byte)(addr & 0xFF);
                        response[10] = (byte)(val >> 8); response[11] = (byte)(val & 0xFF);
                    }
                    else { break; }

                    await s.SendAsync(response, SocketFlags.None, _cts.Token);
                }
            }
            catch { }
            finally { try { s.Dispose(); } catch { } }
        }

        public async ValueTask DisposeAsync()
        {
            await _cts.CancelAsync();
            try { _listener.Stop(); } catch { }
            Task[] snap;
            lock (_tasks) snap = _tasks.ToArray();
            try { await Task.WhenAll(snap).WaitAsync(TimeSpan.FromSeconds(2)); } catch { }
            _cts.Dispose();
        }
    }

    private static PerPlcContext MakeContext(string name, ResponseCache? cache, params BcdTag[] tags)
    {
        var frozen = tags.ToDictionary(t => t.Address).ToFrozenDictionary();
        var map = frozen.Count > 0 ? new BcdTagMap(frozen) : BcdTagMap.Empty;
        return new PerPlcContext
        {
            PlcName  = name,
            TagMap   = map,
            Counters = new ProxyCounters(),
            Logger   = NullLogger.Instance,
            Cache    = cache,
        };
    }

    private static PlcMultiplexer BuildMux(PlcOptions plc, PerPlcContext ctx, bool coalescingEnabled = true)
    {
        return new PlcMultiplexer(
            plc, new ConnectionOptions(),
            new BcdPduPipeline(),
            ctx,
            NullLogger<PlcMultiplexer>.Instance,
            backendConnectPipeline: null,
            coalescingOptions: () => new ReadCoalescingOptions { Enabled = coalescingEnabled, MaxParties = 32 });
    }

    private static async Task<(Socket client, UpstreamPipe pipe, TcpListener proxyListener)>
        ConnectClientAsync(PlcMultiplexer mux, string plcName)
    {
        int proxyPort = PickFreePort();
        var proxyListener = new TcpListener(IPAddress.Loopback, proxyPort);
        proxyListener.Start();

        var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)
        { NoDelay = true };
        await client.ConnectAsync(IPAddress.Loopback, proxyPort);
        var upstream = await proxyListener.AcceptSocketAsync();
        var pipe = new UpstreamPipe(upstream, plcName, NullLogger.Instance);
        _ = Task.Run(() => mux.StartPipeAsync(pipe, CancellationToken.None));
        return (client, pipe, proxyListener);
    }

    // ── Tests ────────────────────────────────────────────────────────────────────

    [Fact]
    public async Task SecondRead_OfSameKey_WithinTtl_HitsCache_NoSecondBackendRoundTrip()
    {
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort);

        using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
        // 16-bit BCD tag at address 100 with 5 s TTL.
        var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // First read — miss, hits backend.
            await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
            var r1 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            ((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001);

            // Second read same key — hit, no second round-trip.
            await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
            var r2 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            ((ushort)((r2[0] << 8) | r2[1])).ShouldBe((ushort)0x0002,
                "the cache hit must restore the requesting client's original TxId");

            backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
            var snap = ctx.Counters.Snapshot();
            snap.CacheHitCount.ShouldBe(1);
            snap.CacheMissCount.ShouldBe(1);
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task BcdDecodedBytes_AreCached_NotRawBcd()
    {
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort) { RegisterValue = 0x1234 }; // raw BCD nibbles

        using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
        var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
            var r1 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            // Response: [..mbap..][0x03][byteCount=2][hi][lo]
            ushort decoded1 = (ushort)((r1[9] << 8) | r1[10]);
            decoded1.ShouldBe((ushort)1234, "first read must be BCD-decoded by the rewriter");

            // Now read again — must be served from cache and still show 1234 (not 0x1234).
            await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
            var r2 = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            ushort decoded2 = (ushort)((r2[9] << 8) | r2[10]);
            decoded2.ShouldBe((ushort)1234,
                "cache must store POST-rewriter bytes — hits must not re-decode and must not return raw BCD");
            backend.RequestCount.ShouldBe(1, "the second read must be served from the cache");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task CacheHit_ShortCircuits_Coalescing()
    {
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort);

        using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
        var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx, coalescingEnabled: true);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // Prime the cache.
            await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            // Subsequent read must hit cache; coalescing miss-counter must NOT increment
            // (cache short-circuited before the coalescing path).
            long missBefore = ctx.Counters.Snapshot().CoalescedMissCount;
            long hitBefore  = ctx.Counters.Snapshot().CoalescedHitCount;

            await c.SendAsync(BuildFc03(0x0002, 100, 1), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            var snap = ctx.Counters.Snapshot();
            snap.CacheHitCount.ShouldBe(1, "second read must hit cache");
            (snap.CoalescedMissCount - missBefore).ShouldBe(0,
                "cache hit must short-circuit the coalescing path entirely — no Miss recorded");
            (snap.CoalescedHitCount - hitBefore).ShouldBe(0,
                "cache hit must short-circuit the coalescing path entirely — no Hit recorded");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task Fc06Write_InvalidatesOverlappingCachedRead()
    {
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort);

        using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
        var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 5000));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // Cache the read.
            await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            cache.Count.ShouldBe(1, "first read must populate the cache");

            // Write to address 100 — must invalidate the cached read.
            await c.SendAsync(BuildFc06(0x0002, 100, 1234), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            // Cache must be empty now.
            cache.Count.ShouldBe(0, "write to a cached address must invalidate the entry");
            ctx.Counters.Snapshot().CacheInvalidations.ShouldBe(1);

            // A subsequent read must miss the cache.
            await c.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            backend.RequestCount.ShouldBe(3, "two reads (one miss, one post-invalidate) + one write = 3 backend round-trips");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task NonOverlappingWrite_DoesNotInvalidate()
    {
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort);

        using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
        var ctx = MakeContext("PLC1", cache,
            BcdTag.Create(100, 16, cacheTtlMs: 5000),
            BcdTag.Create(200, 16, cacheTtlMs: 5000));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // Cache the read at 100.
            await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            // Write to address 200 — distinct register; the cached [100..101) must remain.
            await c.SendAsync(BuildFc06(0x0002, 200, 7), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            cache.Count.ShouldBe(1, "a disjoint write must not invalidate the cached read");

            // Second read on 100 must hit cache.
            await c.SendAsync(BuildFc03(0x0003, 100, 1), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            ctx.Counters.Snapshot().CacheHitCount.ShouldBe(1);
            backend.RequestCount.ShouldBe(2, "first read + write — second read served from cache");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task MultiTagRange_AnyZeroTtl_DisablesCachingForWholeRead()
    {
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort);

        using var cache = new ResponseCache(maxEntriesPerPlc: 64, evictionIntervalMs: 5000);
        // Two tags in the read range [100..102): tag 100 has a TTL, tag 101 does not.
        var ctx = MakeContext("PLC1", cache,
            BcdTag.Create(100, 16, cacheTtlMs: 1000),
            BcdTag.Create(101, 16, cacheTtlMs: 0));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // Two identical reads — both should hit the backend because tag 101 disables
            // caching for the whole [100..102) range.
            await c.SendAsync(BuildFc03(0x0001, 100, 2), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            await c.SendAsync(BuildFc03(0x0002, 100, 2), SocketFlags.None);
            _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);

            backend.RequestCount.ShouldBe(2,
                "any TTL=0 in a multi-tag range must disable caching for the whole read");
            ctx.Counters.Snapshot().CacheHitCount.ShouldBe(0);
            ctx.Counters.Snapshot().CacheMissCount.ShouldBe(0,
                "reads with effective TTL = 0 must not increment either cache counter");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task UncachedReads_BehaveIdentically_ToPhase10()
    {
        // Regression guard: PerPlcContext with Cache = null must behave byte-identically
        // to the non-cached path — every FC03 read produces a backend round-trip
        // (coalescing aside).
        int backendPort = PickFreePort();
        await using var backend = new StubBackend(backendPort);

        // No cache on the context — Cache = null.
        var ctx = MakeContext("PLC1", cache: null);
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = backendPort };
        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // Three sequential identical reads — each hits the backend (no coalescing
            // window with sequential reads, no cache wired).
            for (int i = 0; i < 3; i++)
            {
                await c.SendAsync(BuildFc03((ushort)(i + 1), 100, 1), SocketFlags.None);
                _ = await ReadOneFrameAsync(c, TestContext.Current.CancellationToken);
            }

            backend.RequestCount.ShouldBe(3,
                "without a cache, every read must hit the backend (Phase-10 behaviour)");
            var snap = ctx.Counters.Snapshot();
            snap.CacheHitCount.ShouldBe(0);
            snap.CacheMissCount.ShouldBe(0,
                "cache counters must remain at zero when no cache is wired");
            snap.CoalescedMissCount.ShouldBe(3,
                "every FC03 read must increment CoalescedMissCount per the Phase-10 contract");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }

    [Fact]
    public async Task FailedBackendConnect_OnFirstRead_DoesNotPreventLaterCacheHits_IfCachePrePopulated()
    {
        // Edge case from the design contract: a cache hit short-circuits backend
        // connection establishment. We pre-populate the cache by direct Set, then probe a
        // cache hit while the backend is unreachable.
        int unreachable = PickFreePort(); // listener never started on this port

        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
        var ctx = MakeContext("PLC1", cache, BcdTag.Create(100, 16, cacheTtlMs: 60_000));
        var plc = new PlcOptions { Name = "PLC1", ListenPort = 0, Host = "127.0.0.1", Port = unreachable };

        // Pre-populate the cache with a synthesised response PDU body. This is what the
        // backend reader would have stored after BCD-decoding.
        var key = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 1);
        var now = DateTimeOffset.UtcNow;
        byte[] cachedPdu = [0x03, 0x02, 0x04, 0xD2]; // FC=03, byteCount=2, regValue=0x04D2 (decimal 1234)
        cache.Set(key, new CacheEntry(cachedPdu, now, now.AddSeconds(60), cachedPdu.Length, 0));

        await using var mux = BuildMux(plc, ctx);

        var (c, p, l) = await ConnectClientAsync(mux, plc.Name);
        try
        {
            // The cache check runs BEFORE EnsureBackendConnectedAsync, so we should get a
            // response even though the backend is unreachable.
            using var deadline = new CancellationTokenSource(TimeSpan.FromMilliseconds(800));
            await c.SendAsync(BuildFc03(0x0001, 100, 1), SocketFlags.None, deadline.Token);
            var r1 = await ReadOneFrameAsync(c, deadline.Token);
            ((ushort)((r1[0] << 8) | r1[1])).ShouldBe((ushort)0x0001,
                "cache hits must serve even when the backend is unreachable");
        }
        finally
        {
            c.Dispose();
            await p.DisposeAsync();
            l.Stop();
        }
    }
}
