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

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

/// <summary>
///     Unit tests for <see cref="FocasDriverProbe"/>. Covers the offline-determinable failure
///     paths (invalid JSON, missing host/port, unreachable closed port) plus the Phase-8
///     truthfulness behaviour: a TCP-reachable endpoint that is NOT a FOCAS CNC (a bare listener)
///     must report <c>Ok=false</c>, because the probe now completes a real <c>FocasWireClient</c>
///     session (initiate handshake + <c>cnc_statinfo</c>) rather than degrading to "TCP
///     reachability only" when FWLIB is absent.
///     <para>
///     <b>Live-verify DEFERRED.</b> The happy path (a real CNC completes the handshake + read →
///     "FOCAS session OK") cannot run on this rig — there is no FANUC CNC available at unit-test
///     time. It is verified against the live 31i-B at <c>10.201.31.5</c> (see the implementation
///     plan's deploy/validate step).
///     </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasDriverProbeTests
{
    private static readonly FocasDriverProbe 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 focas://) returns
    ///     Ok=false with a "no host/port" message because <see cref="FocasHostAddress.TryParse"/>
    ///     returns null for the address.
    /// </summary>
    [Fact]
    public async Task MalformedHostAddress_Returns_OkFalse_WithNoHostPortMessage()
    {
        // A foreign URI scheme ("http://…") is rejected by TryParse → null. (A bare
        // "{ip}[:{port}]" without a scheme is now tolerated, so it can't be the malformed case.)
        var result = await Probe.ProbeAsync(
            "{\"devices\":[{\"hostAddress\":\"http://10.0.0.5/\"}]}",
            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 preflight
    // -------------------------------------------------------------------------

    /// <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();

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

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

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        result.Message!.ShouldContain("Connect failed");
        result.Latency.ShouldBeNull();
    }

    // -------------------------------------------------------------------------
    // 4. TCP reachable but not a CNC — wire-session probe must say Ok=false (Phase 8)
    // -------------------------------------------------------------------------

    /// <summary>
    ///     Against an in-process <see cref="TcpListener"/> that accepts the connection but speaks no
    ///     FOCAS (drops each accepted socket), the TCP preflight succeeds but the Phase-2 wire
    ///     session can't complete the initiate handshake + <c>cnc_statinfo</c> read. The probe MUST
    ///     report <c>Ok=false</c> — a bare TCP listener is not a CNC. This is the Phase-8 fix: the
    ///     old probe degraded such a listener to <c>Ok=true</c> "FWLIB absent, TCP reachability
    ///     only", which made any TCP listener look HEALTHY.
    /// </summary>
    [Fact]
    public async Task TcpReachable_NotACnc_Returns_OkFalse()
    {
        // Accept-only listener: completes the TCP handshake but speaks no FOCAS bytes.
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        var port = ((IPEndPoint)listener.LocalEndpoint).Port;
        // Keep accepting so the connect always completes; drop the accepted socket.
        _ = AcceptLoopAsync(listener, TestContext.Current.CancellationToken);

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

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

            // A bare listener is not a CNC — the FOCAS session fails, so the probe is NOT ok.
            result.Ok.ShouldBeFalse(
                $"Expected Ok=false for a non-CNC TCP listener but got: {result.Message}");
            result.Message.ShouldNotBeNull();
            result.Latency.ShouldBeNull();
        }
        finally
        {
            listener.Stop();
        }
    }

    private static async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
    {
        try
        {
            while (!ct.IsCancellationRequested)
            {
                var socket = await listener.AcceptSocketAsync(ct);
                // Drop the connection immediately — we only need the TCP handshake to complete.
                socket.Dispose();
            }
        }
        catch
        {
            // Listener stopped / token cancelled — expected at test teardown.
        }
    }
}
