using System.Net;
using System.Net.Sockets;
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 Xunit;

namespace Mbproxy.Tests.Proxy;

/// <summary>
/// End-to-end proxy forwarding tests.
/// Each test:
/// 1. Starts the proxy host in-process, configured with one PLC pointing at the simulator.
/// 2. Connects NModbus to the proxy's listen port.
/// 3. Asserts the proxy forwards bytes transparently (NoopPduPipeline — no BCD rewriting).
/// </summary>
[Collection(nameof(Mbproxy.Tests.Sim.DL205SimulatorCollection))]
[Trait("Category", "E2E")]
public sealed class ProxyForwardingTests
{
    private readonly Mbproxy.Tests.Sim.DL205SimulatorFixture _sim;

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

    // ── 1. FC03 read HR0 — expect 0xCAFE ───────────────────────────────────────────────

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

        var (proxyPort, host, cts) = await StartProxyAsync();
        await using var _ = new AsyncHostDispose(host, cts);

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

        ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 0, numberOfPoints: 1);

        Assert.Equal(0xCAFE, regs[0]);
    }

    // ── 2a. FC03 read HR1072 — with BCD configured → decoded 1234 ──────────────────────

    [Fact(Timeout = 5_000)]
    public async Task Forward_FC03_HR1072_Returns_Decoded_1234()
    {
        // BcdPduPipeline is active. When BCD tag 1072 (width=16) is configured, the proxy
        // decodes the raw 0x1234 nibbles and the client receives binary 1234.
        if (_sim.SkipReason is not null)
            Assert.Skip(_sim.SkipReason);

        int proxyPort = PickFreePort();

        var config = new Dictionary<string, string?>
        {
            ["Mbproxy:AdminPort"] = "8080",
            [$"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",
            // Configure address 1072 as a 16-bit BCD tag.
            ["Mbproxy:BcdTags:Global:0:Address"] = "1072",
            ["Mbproxy:BcdTags:Global:0:Width"]   = "16",
        };

        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        var host = BuildBcdProxyHost(config);
        using var startCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
        await host.StartAsync(startCts.Token);
        await using var _ = new AsyncHostDispose(host, cts);
        await Task.Delay(150, 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[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);

        // BCD decoded: 0x1234 → binary 1234.
        Assert.Equal(1234, regs[0]);
    }

    // ── 2b. FC03 read HR1072 — without BCD configured → raw 0x1234 ─────────────────────

    [Fact(Timeout = 5_000)]
    public async Task Forward_FC03_HR1072_AsRaw_WhenNotConfigured_Returns_0x1234()
    {
        // When no BCD tag is configured at address 1072, the proxy passes bytes through
        // unmodified. Client receives raw BCD nibbles 0x1234 (= 4660 decimal).
        if (_sim.SkipReason is not null)
            Assert.Skip(_sim.SkipReason);

        var (proxyPort, host, cts) = await StartProxyAsync();
        await using var _ = new AsyncHostDispose(host, cts);

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

        ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 1072, numberOfPoints: 1);

        // No BCD tag configured: raw BCD nibbles pass through.
        Assert.Equal(0x1234, regs[0]);
    }

    // ── 3. FC06 write single register then read back ────────────────────────────────────

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

        var (proxyPort, host, cts) = await StartProxyAsync();
        await using var _ = new AsyncHostDispose(host, cts);

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

        const ushort writeValue = 0xABCD;
        master.WriteSingleRegister(slaveAddress: 1, registerAddress: 200, value: writeValue);

        ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 200, numberOfPoints: 1);
        Assert.Equal(writeValue, regs[0]);
    }

    // ── 4. FC16 write multiple registers then read back ──────────────────────────────────

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

        var (proxyPort, host, cts) = await StartProxyAsync();
        await using var _ = new AsyncHostDispose(host, cts);

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

        ushort[] writeValues = [0x0010, 0x0020, 0x0030];
        master.WriteMultipleRegisters(slaveAddress: 1, startAddress: 201, data: writeValues);

        ushort[] regs = master.ReadHoldingRegisters(slaveAddress: 1, startAddress: 201, numberOfPoints: 3);
        Assert.Equal(writeValues, regs);
    }

    // ── 5. MBAP TxId preserved end-to-end ────────────────────────────────────────────────

    [Fact(Timeout = 5_000)]
    public async Task MbapTxId_IsPreservedEndToEnd()
    {
        // Issue 20 back-to-back FC03 reads with manually-incrementing TxIds (via raw sockets)
        // and verify every response carries the matching TxId.
        // This verifies no mid-stream frame split causes a parse failure under stress.
        if (_sim.SkipReason is not null)
            Assert.Skip(_sim.SkipReason);

        var (proxyPort, host, cts) = await StartProxyAsync();
        await using var _ = new AsyncHostDispose(host, cts);

        using var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
        socket.NoDelay = true;
        await socket.ConnectAsync("127.0.0.1", proxyPort, TestContext.Current.CancellationToken);

        const int count = 20;
        byte[] reqBuf = new byte[12]; // FC03 request frame
        byte[] rspBuf = new byte[260];

        for (ushort txId = 1; txId <= count; txId++)
        {
            // Build FC03 request: read 1 register at address 0.
            // [TxId(2), ProtocolId(2)=0, Length(2)=6, UnitId=1, FC=03, Start(2)=0, Qty(2)=1]
            reqBuf[0] = (byte)(txId >> 8);
            reqBuf[1] = (byte)(txId & 0xFF);
            reqBuf[2] = 0x00; // ProtocolId high
            reqBuf[3] = 0x00; // ProtocolId low
            reqBuf[4] = 0x00; // Length high
            reqBuf[5] = 0x06; // Length low (6 bytes: UnitId + FC + 4 PDU bytes)
            reqBuf[6] = 0x01; // UnitId
            reqBuf[7] = 0x03; // FC03
            reqBuf[8] = 0x00; // Start addr high
            reqBuf[9] = 0x00; // Start addr low
            reqBuf[10] = 0x00; // Qty high
            reqBuf[11] = 0x01; // Qty low

            await socket.SendAsync(reqBuf.AsMemory(), SocketFlags.None, TestContext.Current.CancellationToken);

            // Read response header (7 bytes), then body.
            int read = 0;
            while (read < 7)
                read += await socket.ReceiveAsync(rspBuf.AsMemory(read, 7 - read), SocketFlags.None, TestContext.Current.CancellationToken);

            // Parse response TxId.
            ushort rspTxId = (ushort)((rspBuf[0] << 8) | rspBuf[1]);
            ushort rspLength = (ushort)((rspBuf[4] << 8) | rspBuf[5]);

            Assert.Equal(txId, rspTxId);

            // Drain the response body.
            int bodyLen = rspLength - 1; // length covers UnitId + PDU; we already read UnitId
            if (bodyLen > 0)
            {
                int bodyRead = 0;
                while (bodyRead < bodyLen)
                    bodyRead += await socket.ReceiveAsync(rspBuf.AsMemory(7 + bodyRead, bodyLen - bodyRead), SocketFlags.None, TestContext.Current.CancellationToken);
            }
        }
    }

    // ── 6. Backend connect failure — upstream socket closes cleanly ───────────────────────

    [Fact(Timeout = 5_000)]
    public async Task BackendConnectFailure_ClosesUpstreamCleanly()
    {
        // Point the proxy at port 1 on loopback — guaranteed unreachable.
        // The multiplexer lazily connects to the backend on the first upstream PDU, so
        // we have to actually send a request before the proxy attempts the (failing)
        // backend connect that closes the upstream.
        const int badBackendPort = 1;
        const int backendTimeoutMs = 500; // short timeout for test speed

        int proxyPort = PickFreePort();

        var config = new Dictionary<string, string?>
        {
            ["Mbproxy:AdminPort"] = "8080",
            [$"Mbproxy:Plcs:0:Name"]       = "BadPLC",
            [$"Mbproxy:Plcs:0:ListenPort"] = proxyPort.ToString(),
            [$"Mbproxy:Plcs:0:Host"]       = "127.0.0.1",
            [$"Mbproxy:Plcs:0:Port"]       = badBackendPort.ToString(),
            ["Mbproxy:Connection:BackendConnectTimeoutMs"] = backendTimeoutMs.ToString(),
            ["Mbproxy:Connection:BackendRequestTimeoutMs"] = "3000",
        };

        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(3));
        var host = BuildProxyHost(config);
        await host.StartAsync(cts.Token);

        // Give the proxy a moment to bind.
        await Task.Delay(150, TestContext.Current.CancellationToken);

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

        // Send a Modbus request so the multiplexer attempts the backend connect.
        byte[] req =
        [
            0x00, 0x01, // TxId
            0x00, 0x00, // ProtocolId
            0x00, 0x06, // Length
            0x01,       // UnitId
            0x03,       // FC03
            0x00, 0x00, // Start
            0x00, 0x01, // Qty
        ];
        await client.GetStream().WriteAsync(req, TestContext.Current.CancellationToken);

        // Wait up to BackendConnectTimeoutMs + 600ms for the upstream socket to close.
        // Polly default retry adds extra time, so we account for it in the deadline.
        var deadline = DateTime.UtcNow.AddMilliseconds(backendTimeoutMs + 1500);
        bool closed = false;

        while (DateTime.UtcNow < deadline)
        {
            try
            {
                // A 0-byte receive returns 0 when the remote end closed the socket.
                var buf = new byte[1];
                int n = await client.GetStream()
                    .ReadAsync(buf.AsMemory(), TestContext.Current.CancellationToken);
                if (n == 0) { closed = true; break; }
            }
            catch
            {
                closed = true;
                break;
            }
            await Task.Delay(50, TestContext.Current.CancellationToken);
        }

        await host.StopAsync(cts.Token);

        Assert.True(closed, "Upstream socket should have been closed by the proxy after backend connect failure.");
    }

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

    private async Task<(int proxyPort, IHost host, CancellationTokenSource cts)> StartProxyAsync()
    {
        int proxyPort = PickFreePort();

        var config = new Dictionary<string, string?>
        {
            ["Mbproxy:AdminPort"] = "8080",
            [$"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",
        };

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

        // Give the proxy time to bind.
        await Task.Delay(150, TestContext.Current.CancellationToken);

        var runCts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
        return (proxyPort, host, runCts);
    }

    private static IHost BuildProxyHost(Dictionary<string, string?> config)
    {
        var builder = Host.CreateApplicationBuilder();
        builder.Configuration.AddInMemoryCollection(config);
        // Suppress verbose logging in tests.
        builder.Services.AddSerilog(
            new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
            dispose: false);
        builder.AddMbproxyOptions();
        // Tests in ProxyForwardingTests use NoopPduPipeline to verify raw passthrough
        // (baseline behaviour independent of BCD configuration).
        builder.Services.AddSingleton<IPduPipeline, NoopPduPipeline>();
        builder.Services.AddHostedService<ProxyWorker>();
        return builder.Build();
    }

    private static IHost BuildBcdProxyHost(Dictionary<string, string?> config)
    {
        var builder = Host.CreateApplicationBuilder();
        builder.Configuration.AddInMemoryCollection(config);
        builder.Services.AddSerilog(
            new Serilog.LoggerConfiguration().MinimumLevel.Fatal().CreateLogger(),
            dispose: false);
        builder.AddMbproxyOptions();
        // BCD rewriter pipeline — used by the BCD-decode tests in this file.
        builder.Services.AddSingleton<IPduPipeline, BcdPduPipeline>();
        builder.Services.AddHostedService<ProxyWorker>();
        return builder.Build();
    }

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

    /// <summary>Disposes the host and CTS when the test finishes.</summary>
    private sealed class AsyncHostDispose : IAsyncDisposable
    {
        private readonly IHost _host;
        private readonly CancellationTokenSource _cts;

        public AsyncHostDispose(IHost host, CancellationTokenSource cts)
        {
            _host = host;
            _cts  = cts;
        }

        public async ValueTask DisposeAsync()
        {
            using var stopCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            try { await _host.StopAsync(stopCts.Token); } catch { /* best effort */ }
            _host.Dispose();
            _cts.Dispose();
        }
    }
}
