// Unit tests for TwinCATDriverProbe — the degrade-guarded ADS ReadState Test-Connect probe.
//
// Coverage here is the set of outcomes that are *deterministic on a CI/dev box with no TwinCAT
// runtime*: invalid JSON, missing host/port, an unreachable TCP target, and — the crux — the
// DEGRADE path. On this box there is no AMS router, so once the TCP preflight succeeds against a
// reachable listener the managed ADS client cannot initialise (it throws a "Check for a running
// TwinCAT router instance!" server exception, NOT an ADS device rejection). The probe MUST treat
// that as "TCP reachability only" (Ok=true) so it never regresses below the old TCP-only probe.
//
// The two paths that REQUIRE a live ADS target are LIVE-VERIFY DEFERRED (no TwinCAT runtime on the
// rig): (a) the happy "ADS state: Run/Config/Stop" path (Ok=true with the AdsState name), and
// (b) the route/auth-reject RED path (a reachable router that refuses the route, surfaced as an
// AdsErrorException carrying a TargetPortNotFound / route AdsErrorCode → Ok=false with the
// "check the target's ADS route table" message). Those are exercised against a real TwinCAT XAR /
// remote runtime, out of scope for this unit suite.

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

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

[Trait("Category", "Unit")]
public sealed class TwinCATDriverProbeTests
{
    private static readonly TwinCATDriverProbe 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 device / no host
    // -------------------------------------------------------------------------

    /// <summary>An empty Devices list returns Ok=false with a "no host/port" message.</summary>
    [Fact]
    public async Task NoDevices_Returns_OkFalse_WithNoHostPortMessage()
    {
        var result = await Probe.ProbeAsync(
            "{\"devices\":[]}",
            TimeSpan.FromSeconds(3),
            TestContext.Current.CancellationToken);

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

    /// <summary>
    ///     A first device with a malformed (non-<c>ads://</c>) host address returns Ok=false with a
    ///     "no host/port" message because <see cref="TwinCATAmsAddress.TryParse"/> returns null.
    /// </summary>
    [Fact]
    public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
    {
        var result = await Probe.ProbeAsync(
            "{\"devices\":[{\"hostAddress\":\"not-an-ads-url\"}]}",
            TimeSpan.FromSeconds(3),
            TestContext.Current.CancellationToken);

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

    // -------------------------------------------------------------------------
    // 3. Unreachable target — TCP preflight fails first
    // -------------------------------------------------------------------------

    /// <summary>
    ///     A closed port causes the TCP preflight <c>ConnectAsync</c> to throw
    ///     <see cref="SocketException"/>; the probe returns Ok=false with "Connect failed".
    ///     The port is reserved then released so the OS refuses the connection immediately.
    /// </summary>
    [Fact]
    public async Task ClosedPort_Returns_OkFalse_WithConnectFailedMessage()
    {
        // Reserve an ephemeral port then release it so the port is definitely closed.
        var reserved = new TcpListener(IPAddress.Loopback, 0);
        reserved.Start();
        var port = ((IPEndPoint)reserved.LocalEndpoint).Port;
        reserved.Stop();

        // 127.0.0.1 as the first four AMS-Net-ID octets → the probe's TCP target is 127.0.0.1.
        var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ads://127.0.0.1.1.1:{port}\"}}]}}";

        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
            TestContext.Current.CancellationToken);
        cts.CancelAfter(TimeSpan.FromSeconds(5));

        var result = await Probe.ProbeAsync(
            configJson,
            TimeSpan.FromSeconds(3),
            cts.Token);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        // Either a fast SocketException ("Connect failed") or, if the OS stalls, a timeout.
        var lower = result.Message!.ToLowerInvariant();
        (lower.Contains("connect failed") || lower.Contains("timed out")).ShouldBeTrue(
            $"Expected 'Connect failed' or 'timed out' but got: {result.Message}");
        result.Latency.ShouldBeNull();
    }

    /// <summary>
    ///     A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) with a short
    ///     cancellation window times out (or fails fast on EHOSTUNREACH) at the TCP preflight.
    /// </summary>
    [Fact]
    public async Task BlackHoleTarget_Returns_OkFalse_WithTimedOutOrConnectFailed()
    {
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
            TestContext.Current.CancellationToken);
        cts.CancelAfter(TimeSpan.FromSeconds(1));

        var configJson = "{\"devices\":[{\"hostAddress\":\"ads://192.0.2.1.1.1:851\"}]}";
        var result = await Probe.ProbeAsync(
            configJson,
            TimeSpan.FromSeconds(1),
            cts.Token);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        var lower = result.Message!.ToLowerInvariant();
        (lower.Contains("timed out") || lower.Contains("connect failed")).ShouldBeTrue(
            $"Expected 'timed out' or 'Connect failed' but got: {result.Message}");
        result.Latency.ShouldBeNull();
    }

    // -------------------------------------------------------------------------
    // 4. DEGRADE path — TCP reachable, but the ADS handshake can't be attempted here
    // -------------------------------------------------------------------------

    /// <summary>
    ///     The crux. A reachable TCP listener satisfies the preflight, but on this box (no TwinCAT
    ///     AMS router) the managed ADS client cannot establish a session — <c>AdsClient.Connect</c>
    ///     throws an environment/router exception (NOT an ADS device rejection). The probe MUST
    ///     DEGRADE: <c>Ok=true</c> with the "TCP reachability only" note, never a false RED. This
    ///     guarantees the new probe never reports worse than the old TCP-only probe on a host with
    ///     no ADS runtime.
    /// </summary>
    [Fact]
    public async Task ReachableTcp_NoAdsRuntime_Degrades_To_OkTrue_WithTcpOnlyNote()
    {
        // Stand up a real TCP listener and accept (then drop) connections so the preflight
        // ConnectAsync succeeds. We never speak ADS — the ADS handshake is what must degrade.
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;

        using var acceptCts = CancellationTokenSource.CreateLinkedTokenSource(
            TestContext.Current.CancellationToken);
        var acceptLoop = Task.Run(async () =>
        {
            try
            {
                while (true)
                {
                    var client = await listener.AcceptTcpClientAsync(acceptCts.Token);
                    // Hold the socket briefly so the preflight sees a clean accept, then close.
                    _ = Task.Run(async () =>
                    {
                        await Task.Delay(200);
                        client.Dispose();
                    });
                }
            }
            catch (OperationCanceledException) { /* accept cancelled — expected on teardown */ }
            catch (ObjectDisposedException) { /* listener stopped — expected on teardown */ }
            catch (SocketException) { /* listener stopped — expected on teardown */ }
        });

        try
        {
            var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ads://127.0.0.1.1.1:{port}\"}}]}}";

            using var cts = CancellationTokenSource.CreateLinkedTokenSource(
                TestContext.Current.CancellationToken);
            cts.CancelAfter(TimeSpan.FromSeconds(10));

            var result = await Probe.ProbeAsync(
                configJson,
                TimeSpan.FromSeconds(3),
                cts.Token);

            // DEGRADE guard: must be Ok=true (never worse than TCP-only), with the explicit note
            // and a latency. If on some box AdsClient instead threw synchronously at construction,
            // the probe still degrades the same way — this assertion holds either way.
            result.Ok.ShouldBeTrue(
                $"Degrade guard violated — probe returned a worse-than-TCP result: {result.Message}");
            result.Message.ShouldNotBeNull();
            result.Message!.ShouldContain("ADS handshake unavailable on this host");
            result.Message!.ShouldContain("TCP reachability only");
            result.Latency.ShouldNotBeNull();
        }
        finally
        {
            await acceptCts.CancelAsync();
            listener.Stop();
            try { await acceptLoop; } catch { /* ignore teardown races */ }
        }
    }
}
