using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests;

/// <summary>
///     Unit tests for <see cref="S7DriverProbe"/>. Uses in-process <see cref="TcpListener"/>
///     instances on 127.0.0.1 to exercise all failure paths without a real PLC.
///     The happy path (real S7 setup-communication exchange) is covered in live integration
///     tests against a python-snap7 simulator.
/// </summary>
[Trait("Category", "Unit")]
public sealed class S7DriverProbeTests
{
    private static readonly S7DriverProbe Probe = new();

    // -------------------------------------------------------------------------
    // 1. Invalid JSON
    // -------------------------------------------------------------------------

    /// <summary>Invalid JSON returns Ok=false with a message containing "invalid".</summary>
    [Fact]
    public async Task InvalidJson_Returns_OkFalse_WithInvalidMessage()
    {
        var result = await Probe.ProbeAsync(
            "not-valid-json{{{",
            TimeSpan.FromSeconds(3),
            TestContext.Current.CancellationToken);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        result.Message!.ToLowerInvariant().ShouldContain("invalid");
        result.Latency.ShouldBeNull();
    }

    // -------------------------------------------------------------------------
    // 2. Config with no host
    // -------------------------------------------------------------------------

    /// <summary>Config that serialises to null host returns Ok=false with a "no host/port" message.</summary>
    [Fact]
    public async Task NoHost_Returns_OkFalse_WithNoHostPortMessage()
    {
        // Host defaults to "127.0.0.1" in S7DriverOptions, but an empty string
        // is what the spec's "no host" path covers. Provide an empty host explicitly.
        var result = await Probe.ProbeAsync(
            "{\"host\":\"\",\"port\":102}",
            TimeSpan.FromSeconds(3),
            TestContext.Current.CancellationToken);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        result.Message!.ShouldContain("no host/port");
        result.Latency.ShouldBeNull();
    }

    // -------------------------------------------------------------------------
    // 3. Unreachable / refused port
    // -------------------------------------------------------------------------

    /// <summary>A closed port returns Ok=false with a "Connect failed" message.</summary>
    [Fact]
    public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
    {
        // Bind a listener, read the OS-assigned port, then stop it so the port
        // is guaranteed closed (no process listening) when the probe runs.
        var reserved = new TcpListener(IPAddress.Loopback, 0);
        reserved.Start();
        var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
        reserved.Stop();

        var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}";
        var result = await Probe.ProbeAsync(
            configJson,
            TimeSpan.FromSeconds(3),
            TestContext.Current.CancellationToken);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        // SocketException maps to "Connect failed: <SocketErrorCode>" or similar.
        result.Message!.ShouldContain("Connect failed");
        result.Latency.ShouldBeNull();
    }

    // -------------------------------------------------------------------------
    // 4. TCP accepts then immediately closes (non-S7 server)
    // -------------------------------------------------------------------------

    /// <summary>
    ///     A listener that accepts the TCP connection and then immediately closes it without
    ///     exchanging any bytes simulates a non-S7 server (wrong rack/slot, plain TCP echo,
    ///     etc.). The probe must return Ok=false with a message containing "handshake failed".
    ///     A short probe timeout (1 s) ensures the internal S7netplus socket-read timeout
    ///     fires well within the test wall-clock budget of 10 s.
    /// </summary>
    [Fact]
    public async Task TcpAcceptsThenCloses_Returns_OkFalse_WithHandshakeFailedMessage()
    {
        // Use a generous outer CTS so the test itself never races with the probe timeout.
        using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));

        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;

        // Accept one connection from the background and immediately close it —
        // no S7 bytes exchanged, so OpenAsync must fail.
        var serverTask = Task.Run(async () =>
        {
            try
            {
                using var client = await listener.AcceptTcpClientAsync(cts.Token);
                // Immediately dispose → TCP RST/FIN; the S7 handshake receives nothing.
            }
            catch (OperationCanceledException) { /* test timed out */ }
        }, cts.Token);

        try
        {
            var configJson = $"{{\"host\":\"127.0.0.1\",\"port\":{port}}}";
            // Pass a 1 s probe timeout so S7netplus's internal read-timeout fires quickly,
            // and the 10 s outer CTS does NOT cancel first — the OCE will be from the
            // internal handshakeCts, routing to the "handshake failed" branch.
            var result = await Probe.ProbeAsync(
                configJson,
                TimeSpan.FromSeconds(1),
                cts.Token);

            result.Ok.ShouldBeFalse();
            result.Message.ShouldNotBeNull();
            result.Message!.ShouldContain("handshake failed");
            result.Latency.ShouldBeNull();
        }
        finally
        {
            listener.Stop();
            try { await serverTask; } catch { /* ignore */ }
        }
    }
}
