// Happy-path PCCC handshake is live-verify DEFERRED — no PLC5/SLC sim on the dev rig.
// The libplctag code path (LibplctagLegacyTagRuntime.InitializeAsync + Status mapping) is
// verified-by-proxy via the AbCip probe, which exercises the same libplctag native layer.

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

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

/// <summary>
///     Unit tests for <see cref="AbLegacyDriverProbe"/>. Covers the offline-determinable
///     failure paths: invalid JSON, missing host/port, malformed host address, an unreachable
///     (TCP-level rejected) target, and a black-hole target with a short timeout.
///     The happy path (real PCCC session + <c>Ok=true</c>) requires a live PLC5/SLC
///     simulator and is deferred — no such sim exists on the dev rig.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyDriverProbeTests
{
    private static readonly AbLegacyDriverProbe 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>
    ///     A config with 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 config whose first device carries a malformed host address (not ab://) returns
    ///     Ok=false with a "no host/port" message because <see cref="AbLegacyHostAddress.TryParse"/>
    ///     returns null for the address.
    /// </summary>
    [Fact]
    public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
    {
        // "not-an-ab-url" is not an ab:// URL — TryParse returns null.
        var result = await Probe.ProbeAsync(
            "{\"devices\":[{\"hostAddress\":\"not-an-ab-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 connect fails at Phase 1
    // -------------------------------------------------------------------------

    /// <summary>
    ///     A closed port causes <c>ConnectAsync</c> to throw <see cref="SocketException"/>;
    ///     the probe must return Ok=false with a "Connect failed" message.
    ///     The port is reserved then released so the OS refuses the connection immediately
    ///     (no black-hole delay needed).
    /// </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();

        // Use a short timeout — we only need Phase 1 to fail fast.
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
            TestContext.Current.CancellationToken);
        cts.CancelAfter(TimeSpan.FromSeconds(5));

        var configJson = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]}}";
        var result = await Probe.ProbeAsync(
            configJson,
            TimeSpan.FromSeconds(3),
            cts.Token);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        // Phase 1 SocketException → "Connect failed: <SocketErrorCode>"
        result.Message!.ShouldContain("Connect failed");
        result.Latency.ShouldBeNull();
    }

    /// <summary>
    ///     A target on a non-routable IP (192.0.2.1 — TEST-NET-1, RFC 5737) causes the
    ///     probe to time out at Phase 1 when given a very short cancellation window.
    ///     The probe must return Ok=false with a "timed out" or "Connect failed" message.
    /// </summary>
    [Fact]
    public async Task BlackHoleTarget_TimedOut_Returns_OkFalse_WithTimedOutOrConnectFailedMessage()
    {
        // 192.0.2.1 is TEST-NET-1 (RFC 5737) — reserved, non-routable, connection black-holes.
        // Use a 1-second cancellation so the test stays fast on any network.
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(
            TestContext.Current.CancellationToken);
        cts.CancelAfter(TimeSpan.FromSeconds(1));

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

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        // Either "timed out" (OperationCanceledException) or "Connect failed" (EHOSTUNREACH on
        // some network stacks) — both are acceptable "not reachable" outcomes.
        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();
    }
}
