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

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

/// <summary>
///     Tests for <see cref="MqttDriverProbe"/>. Mirrors <c>ModbusDriverProbeTests</c>'
///     shape: a real loopback listener drives server-side behaviour so the probe's CONNECT
///     handshake is exercised against something real rather than mocked.
/// </summary>
[Trait("Category", "Unit")]
public sealed class MqttDriverProbeTests
{
    private static readonly TimeSpan QuickTimeout = TimeSpan.FromSeconds(3);

    /// <summary>A loopback listener that accepts the TCP connection and then never answers CONNACK —
    /// the shape that actually wedges a client. A refused port fails instantly with ECONNREFUSED and
    /// would pass whether or not the probe's deadline logic works at all, so the timeout test below
    /// needs this rather than a closed port.</summary>
    private sealed class BlackholeBroker : IDisposable
    {
        private readonly TcpListener _listener;
        private readonly CancellationTokenSource _cts = new();
        private readonly List<TcpClient> _accepted = [];

        public BlackholeBroker()
        {
            _listener = new TcpListener(IPAddress.Loopback, 0);
            _listener.Start();
            Port = ((IPEndPoint)_listener.LocalEndpoint).Port;
            _ = Task.Run(AcceptLoopAsync);
        }

        public int Port { get; }

        private async Task AcceptLoopAsync()
        {
            try
            {
                while (!_cts.IsCancellationRequested)
                {
                    var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false);
                    lock (_accepted)
                    {
                        _accepted.Add(client);
                    }
                    // Accept the TCP handshake and then say nothing — never a CONNACK.
                }
            }
            catch (Exception)
            {
                // Listener stopped / cancelled — expected at teardown.
            }
        }

        public void Dispose()
        {
            _cts.Cancel();
            _listener.Stop();
            lock (_accepted)
            {
                foreach (var client in _accepted)
                {
                    client.Dispose();
                }
            }
            _cts.Dispose();
        }
    }

    private static TcpListener StartListener()
    {
        var l = new TcpListener(IPAddress.Loopback, 0);
        l.Start();
        return l;
    }

    private static int ListenerPort(TcpListener l) => ((IPEndPoint)l.LocalEndpoint).Port;

    [Fact]
    public void DriverType_IsCanonicalMqtt() => new MqttDriverProbe().DriverType.ShouldBe("Mqtt");

    /// <summary>The plan's own vacuous-pass trap: a closed port fails instantly (ECONNREFUSED)
    /// regardless of whether the deadline logic works. Kept as the "refused" leg, not the
    /// "deadline" leg — see <see cref="ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang"/>
    /// for the real timeout-path exercise.</summary>
    [Fact]
    public async Task ProbeAsync_DeadBroker_ReturnsFailedResult_WithinDeadline()
    {
        var listener = StartListener();
        var port = ListenerPort(listener);
        listener.Stop();

        var r = await new MqttDriverProbe().ProbeAsync(
            $$"""{"host":"127.0.0.1","port":{{port}},"useTls":false,"connectTimeoutSeconds":2}""",
            QuickTimeout,
            CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNullOrEmpty();
    }

    /// <summary>The real timeout-path exercise: a broker that completes the TCP handshake and then
    /// never answers CONNACK must still fail at the configured deadline rather than hang.</summary>
    [Fact]
    public async Task ProbeAsync_BlackholeBroker_FailsWithinDeadline_NoHang()
    {
        using var blackhole = new BlackholeBroker();
        var config = $$"""{"host":"127.0.0.1","port":{{blackhole.Port}},"useTls":false,"connectTimeoutSeconds":1}""";

        var sw = Stopwatch.StartNew();
        var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);
        sw.Stop();

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNullOrEmpty();
        r.Message.ShouldContain("timed out", Case.Insensitive);
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(10));
    }

    [Fact]
    public async Task ProbeAsync_InvalidJson_ReturnsFailedResult_NotThrown()
    {
        var r = await new MqttDriverProbe().ProbeAsync("not valid json {{", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNullOrEmpty();
    }

    [Fact]
    public async Task ProbeAsync_NoHost_ReturnsFailedResult()
    {
        var r = await new MqttDriverProbe().ProbeAsync(
            """{"host":"","port":0}""", QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNullOrEmpty();
    }

    /// <summary>Never leak a configured password into the probe's result message — it surfaces
    /// directly in the AdminUI Test-connect UI.</summary>
    [Fact]
    public async Task ProbeAsync_NeverLeaksPasswordIntoMessage()
    {
        var listener = StartListener();
        var port = ListenerPort(listener);
        listener.Stop();

        const string secret = "S3cr3tSquirrel!!";
        var config = $$"""
            {"host":"127.0.0.1","port":{{port}},"useTls":false,"username":"probe-user","password":"{{secret}}","connectTimeoutSeconds":2}
            """;

        var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNullOrEmpty();
        r.Message.ShouldNotContain(secret);
    }

    /// <summary>
    ///     Enum fields (protocolVersion) must round-trip by name, per the repo's documented
    ///     enum-serialization trap — a numeric-only seam faults AdminUI-authored configs.
    ///     <para>
    ///         <b>The assertions are the whole point.</b> "Ok is false with a non-empty message" is
    ///         ALSO what a JSON-parse failure produces, so asserting only that would stay green if
    ///         <c>JsonStringEnumConverter</c> were ever dropped from <see cref="MqttJson.Options"/>
    ///         — i.e. the exact regression this test exists to catch. So it asserts the probe got
    ///         PAST the parse (message is not the <c>"Config JSON is invalid"</c> shape) and reached
    ///         the network (the message names the target endpoint).
    ///     </para>
    /// </summary>
    [Fact]
    public async Task ProbeAsync_EnumAsName_ParsesAndReachesTheNetwork()
    {
        var listener = StartListener();
        var port = ListenerPort(listener);
        listener.Stop();

        var config = $$"""
            {"host":"127.0.0.1","port":{{port}},"useTls":false,"protocolVersion":"V500","connectTimeoutSeconds":2}
            """;

        var r = await new MqttDriverProbe().ProbeAsync(config, QuickTimeout, CancellationToken.None);

        r.Ok.ShouldBeFalse();
        r.Message.ShouldNotBeNullOrEmpty();
        r.Message.ShouldNotStartWith(
            "Config JSON is invalid",
            Case.Insensitive,
            "the probe never parsed the config — 'V500' was rejected, so the shared JSON options " +
            "lost their JsonStringEnumConverter");
        r.Message.ShouldNotStartWith("Config JSON deserialized to null");
        r.Message.ShouldNotStartWith("Config has no host/port");
        // A network-level marker: the probe got as far as dialling the (closed) loopback port.
        r.Message.ShouldContain($"127.0.0.1:{port}");
    }
}
