using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;

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

/// <summary>
///     Task 15 — <see cref="MTConnectDriverFactoryExtensions"/>: the seam that turns a
///     <c>DriverInstance</c> row's <c>DriverConfig</c> JSON into a live <see cref="MTConnectDriver"/>.
///     <para>
///         Three properties carry the weight here. (1) <b>One parser.</b> The factory delegates to
///         <c>MTConnectDriver.ParseOptions</c> rather than deserializing a second DTO, so the config
///         document has a single authority and the runtime's <c>ReinitializeAsync</c> path cannot
///         drift from the bootstrap path. (2) <b>Connection-free construction.</b> The Wave-0
///         universal browser builds a throwaway instance purely to read
///         <c>SupportsOnlineDiscovery</c>, so a factory that dialled would open a socket per AdminUI
///         browse render against a possibly-unreachable Agent. (3) <b>No dropped capability.</b> The
///         registry hands the runtime an <see cref="IDriver"/>; every capability is resolved by
///         pattern-matching the concrete instance, so the interface set on the object the factory
///         returns IS the dispatch surface.
///     </para>
/// </summary>
public sealed class MTConnectFactoryTests
{
    private const uint Good = 0x00000000u;
    private const uint BadWaitingForInitialData = 0x80320000u;
    private const uint BadNodeIdUnknown = 0x80340000u;

    // ----------------------------------------------------------------- construction

    /// <summary>The plan's headline case: a one-key document is a complete MTConnect config.</summary>
    [Fact]
    public void Factory_builds_a_driver_from_minimal_config()
    {
        var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");

        d.DriverType.ShouldBe("MTConnect");
        d.DriverInstanceId.ShouldBe("mt1");
    }

    /// <summary>
    ///     The factory's own type name and the instance's <see cref="IDriver.DriverType"/> must be
    ///     the same string, or a registered factory would materialise a driver the runtime looks up
    ///     under a different key.
    /// </summary>
    [Fact]
    public void DriverTypeName_matches_the_instances_DriverType()
    {
        var d = MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"http://a:5000\"}");

        MTConnectDriverFactoryExtensions.DriverTypeName.ShouldBe("MTConnect");
        d.DriverType.ShouldBe(MTConnectDriverFactoryExtensions.DriverTypeName);
    }

    // ----------------------------------------------------------------- rejection

    /// <summary>
    ///     No <c>agentUri</c> means there is no Agent to talk to. Throwing is correct: the browser's
    ///     <c>CanBrowse</c> catches factory exceptions (a half-authored config just disables the
    ///     picker), and a deployment carrying such a row must fail rather than register a driver
    ///     pointed at nothing.
    /// </summary>
    [Fact]
    public void Factory_rejects_config_without_agentUri()
        => Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{}"));

    /// <summary>
    ///     A whitespace-only <c>agentUri</c> is the same absence spelled differently — it must not
    ///     slip past the required check and become a driver dialling an empty URI.
    /// </summary>
    [Fact]
    public void Factory_rejects_a_blank_agentUri()
        => Should.Throw<InvalidOperationException>(
            () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{\"agentUri\":\"   \"}"));

    /// <summary>
    ///     Unparseable JSON faults loudly rather than silently yielding a defaulted driver — the
    ///     "empty bytes are not an empty configuration" rule (#485) applied at the factory seam.
    /// </summary>
    [Fact]
    public void Factory_rejects_unparseable_json_rather_than_defaulting()
    {
        var ex = Should.Throw<InvalidOperationException>(
            () => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "{ this is not json"));

        ex.Message.ShouldContain("mt1");
    }

    /// <summary>A JSON <c>null</c> document is not a config either.</summary>
    [Fact]
    public void Factory_rejects_a_null_json_document()
        => Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance("mt1", "null"));

    /// <summary>An empty config string has nothing to build from; the id likewise.</summary>
    [Theory]
    [InlineData("mt1", "")]
    [InlineData("mt1", "   ")]
    [InlineData("", "{\"agentUri\":\"http://a:5000\"}")]
    public void Factory_rejects_empty_arguments(string id, string json)
        => Should.Throw<ArgumentException>(() => MTConnectDriverFactoryExtensions.CreateInstance(id, json));

    // ----------------------------------------------------------------- connection-free

    /// <summary>
    ///     <b>The factory opens no socket.</b> Asserted directly: a real listener is bound, the
    ///     driver is built against its address, and nothing ever arrives on the accept queue. A
    ///     regression here would make every AdminUI browse render dial the Agent.
    /// </summary>
    [Fact]
    public async Task CreateInstance_opens_no_socket_to_the_configured_agent()
    {
        var listener = new TcpListener(IPAddress.Loopback, 0);
        listener.Start();
        try
        {
            var port = ((IPEndPoint)listener.LocalEndpoint).Port;

            var sw = Stopwatch.StartNew();
            var driver = MTConnectDriverFactoryExtensions.CreateInstance(
                "mt1", $"{{\"agentUri\":\"http://127.0.0.1:{port}\"}}");
            sw.Stop();

            driver.ShouldNotBeNull();

            // Generous grace for a connect that a background thread might land late.
            await Task.Delay(250, TestContext.Current.CancellationToken);

            listener.Pending().ShouldBeFalse(
                "MTConnectDriverFactoryExtensions.CreateInstance dialled the Agent — the browser " +
                "builds a throwaway instance per browse probe, so construction must open nothing.");
            sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
        }
        finally
        {
            listener.Stop();
            listener.Dispose();
        }
    }

    /// <summary>
    ///     The same property against an address that can never answer (TEST-NET-1, RFC 5737):
    ///     construction returns promptly instead of blocking on a connect that will only end in a
    ///     timeout.
    /// </summary>
    [Fact]
    public void CreateInstance_returns_immediately_for_a_black_hole_agent()
    {
        var sw = Stopwatch.StartNew();
        var driver = MTConnectDriverFactoryExtensions.CreateInstance(
            "mt1", "{\"agentUri\":\"http://192.0.2.1:5000\"}");
        sw.Stop();

        driver.DriverInstanceId.ShouldBe("mt1");
        sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2));
    }

    // ----------------------------------------------------------------- registry wiring

    /// <summary>
    ///     <see cref="MTConnectDriverFactoryExtensions.Register"/> puts the type in the registry
    ///     under the same key the bootstrapper looks up, and the registered delegate — not just the
    ///     static helper — builds a working instance.
    /// </summary>
    [Fact]
    public void Register_adds_the_type_and_the_registered_delegate_builds_a_driver()
    {
        var registry = new DriverFactoryRegistry();

        MTConnectDriverFactoryExtensions.Register(registry);

        registry.RegisteredTypes.ShouldContain("MTConnect");

        var factory = registry.TryGet("MTConnect");
        factory.ShouldNotBeNull();

        var driver = factory("mt-from-registry", "{\"agentUri\":\"http://a:5000\"}");

        driver.DriverInstanceId.ShouldBe("mt-from-registry");
        driver.DriverType.ShouldBe("MTConnect");
    }

    /// <summary>
    ///     <b>The anti-dormancy pin.</b> The runtime resolves every optional capability by
    ///     pattern-matching the instance the registry hands back, so a capability the driver
    ///     implements but the factory's return path hides would be implemented-yet-never-dispatched
    ///     — a failure shape this repo has shipped twice. Asserted on the <see cref="IDriver"/>
    ///     the registry delegate returns, which is exactly what the bootstrapper holds.
    /// </summary>
    [Fact]
    public void Registered_delegate_returns_an_instance_carrying_every_capability()
    {
        var registry = new DriverFactoryRegistry();
        MTConnectDriverFactoryExtensions.Register(registry);

        IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");

        driver.ShouldBeOfType<MTConnectDriver>();
        (driver is IReadable).ShouldBeTrue("MTConnect must dispatch reads");
        (driver is ISubscribable).ShouldBeTrue("MTConnect must dispatch subscriptions");
        (driver is ITagDiscovery).ShouldBeTrue("MTConnect must dispatch browse/discovery");
        (driver is IHostConnectivityProbe).ShouldBeTrue("MTConnect must dispatch host connectivity");
        (driver is IRediscoverable).ShouldBeTrue("MTConnect must dispatch rediscovery");
    }

    /// <summary>
    ///     The other half of the capability pin: MTConnect's Agent surface is read-only, so the
    ///     instance must NOT be <see cref="IWritable"/>. If it ever became writable, the write gate
    ///     would start offering an operation the protocol cannot honour.
    /// </summary>
    [Fact]
    public void Registered_delegate_returns_an_instance_that_is_not_writable()
    {
        var registry = new DriverFactoryRegistry();
        MTConnectDriverFactoryExtensions.Register(registry);

        IDriver driver = registry.TryGet("MTConnect")!("mt1", "{\"agentUri\":\"http://a:5000\"}");

        (driver is IWritable).ShouldBeFalse("the MTConnect Agent surface is read-only");
    }

    /// <summary>Registering into a null registry is a wiring bug, not a silent no-op.</summary>
    [Fact]
    public void Register_rejects_a_null_registry()
        => Should.Throw<ArgumentNullException>(() => MTConnectDriverFactoryExtensions.Register(null!));

    // ----------------------------------------------------------------- config round-trip

    /// <summary>
    ///     An authored <c>tags</c> array reaches <c>options.Tags</c>. Observed through the
    ///     observation index the constructor builds from those options: an authored id answers
    ///     <c>BadWaitingForInitialData</c> ("subscribed, no value yet"), an unauthored one answers
    ///     <c>BadNodeIdUnknown</c>. Status codes are literals so a wrong constant cannot be masked
    ///     by both sides sharing one symbol.
    /// </summary>
    [Fact]
    public void Authored_tags_round_trip_into_the_driver_options()
    {
        const string json = """
        {
          "agentUri": "http://a:5000",
          "tags": [
            { "fullName": "dev1_pos", "driverDataType": "Float64" },
            { "fullName": "dev1_execution", "driverDataType": "String" }
          ]
        }
        """;

        var driver = MTConnectDriverFactoryExtensions.CreateInstance("mt1", json);

        driver.ObservationIndex.Get("dev1_pos").StatusCode.ShouldBe(BadWaitingForInitialData);
        driver.ObservationIndex.Get("dev1_execution").StatusCode.ShouldBe(BadWaitingForInitialData);
        driver.ObservationIndex.Get("dev1_never_authored").StatusCode.ShouldBe(BadNodeIdUnknown);
    }

    /// <summary>
    ///     An enum authored as a <b>name</b> parses to that member — proved by behaviour, not by
    ///     reading the option back: a <c>Float64</c> tag coerces the Agent's text into a real
    ///     <see cref="double"/>, whereas the enum's member 0 (<c>Boolean</c>) or a defaulted
    ///     <c>String</c> would not.
    /// </summary>
    [Fact]
    public void Enum_fields_authored_as_names_parse_to_that_member()
    {
        var driver = MTConnectDriverFactoryExtensions.CreateInstance(
            "mt1",
            """
            { "agentUri": "http://a:5000",
              "tags": [ { "fullName": "dev1_pos", "driverDataType": "Float64" } ] }
            """);

        driver.ObservationIndex.Apply(new MTConnectStreamsResult(
            InstanceId: 1L,
            NextSequence: 2L,
            FirstSequence: 1L,
            Observations: [new MTConnectObservation(
                "dev1_pos", "12.5", new DateTime(2026, 7, 24, 12, 0, 0, DateTimeKind.Utc), false)]));

        var snap = driver.ObservationIndex.Get("dev1_pos");

        snap.StatusCode.ShouldBe(Good);
        snap.Value.ShouldBeOfType<double>().ShouldBe(12.5d);
    }

    /// <summary>
    ///     <b>The enum-serialization trap.</b> The factory deliberately carries no
    ///     <c>JsonStringEnumConverter</c>: a numerically-serialized enum — the shape an AdminUI page
    ///     that serialized the enum by value would emit — must fault at deploy rather than silently
    ///     landing on member 0 (<c>Boolean</c>), which would mis-coerce every value of that tag.
    /// </summary>
    [Fact]
    public void Numerically_serialized_enum_faults_rather_than_landing_on_member_zero()
        => Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance(
            "mt1",
            """
            { "agentUri": "http://a:5000",
              "tags": [ { "fullName": "dev1_pos", "driverDataType": 8 } ] }
            """));

    /// <summary>An enum name that names no member is an authoring error, reported with the field.</summary>
    [Fact]
    public void Unknown_enum_name_faults_naming_the_offending_tag()
    {
        var ex = Should.Throw<InvalidOperationException>(() => MTConnectDriverFactoryExtensions.CreateInstance(
            "mt1",
            """
            { "agentUri": "http://a:5000",
              "tags": [ { "fullName": "dev1_pos", "driverDataType": "Flot64" } ] }
            """));

        ex.Message.ShouldContain("dev1_pos");
    }

    /// <summary>
    ///     Scalar knobs reach the options too — the factory must not quietly drop a section it does
    ///     not itself read. Proved through the one seam a non-initialized driver exposes: an
    ///     authored <c>reconnect</c> block changes the backoff the driver would apply.
    /// </summary>
    [Fact]
    public void Reconnect_and_probe_sections_round_trip_into_the_driver_options()
    {
        const string json = """
        {
          "agentUri": "http://a:5000",
          "deviceName": "dev1",
          "requestTimeoutMs": 1234,
          "reconnect": { "minBackoffMs": 500, "maxBackoffMs": 4000, "backoffMultiplier": 3.0 },
          "probe": { "enabled": false, "intervalMs": 7000, "timeoutMs": 900 }
        }
        """;

        var options = MTConnectDriver.ParseOptions("mt1", json);

        options.AgentUri.ShouldBe("http://a:5000");
        options.DeviceName.ShouldBe("dev1");
        options.RequestTimeoutMs.ShouldBe(1234);
        options.Reconnect.MinBackoffMs.ShouldBe(500);
        options.Reconnect.MaxBackoffMs.ShouldBe(4000);
        options.Reconnect.BackoffMultiplier.ShouldBe(3.0);
        options.Probe.Enabled.ShouldBeFalse();
        options.Probe.Interval.ShouldBe(TimeSpan.FromMilliseconds(7000));
        options.Probe.Timeout.ShouldBe(TimeSpan.FromMilliseconds(900));

        // …and the factory is built on exactly that parse, not a second DTO: the same document
        // through the factory must produce an instance, and the same rejection rules (asserted
        // above) hold for both entry points.
        MTConnectDriverFactoryExtensions.CreateInstance("mt1", json).DriverInstanceId.ShouldBe("mt1");
    }
}
