using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;

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

[Trait("Category", "Unit")]
public sealed class FocasScaffoldingTests
{
    // ---- FocasHostAddress ----

    /// <summary>Verifies FocasHostAddress.TryParse correctly parses valid addresses.</summary>
    /// <param name="input">The input address string to parse.</param>
    /// <param name="host">The expected host after parsing.</param>
    /// <param name="port">The expected port after parsing.</param>
    [Theory]
    [InlineData("focas://10.0.0.5:8193", "10.0.0.5", 8193)]
    [InlineData("focas://10.0.0.5", "10.0.0.5", 8193)]                         // default port
    [InlineData("focas://cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)]
    [InlineData("focas://10.0.0.5:12345", "10.0.0.5", 12345)]
    [InlineData("FOCAS://10.0.0.5:8193", "10.0.0.5", 8193)]                    // case-insensitive scheme
    [InlineData("10.201.31.5:8193", "10.201.31.5", 8193)]                     // scheme-less (AdminUI-persisted form)
    [InlineData("10.0.0.5", "10.0.0.5", 8193)]                                // scheme-less, default port
    [InlineData("cnc-01.factory.internal:8193", "cnc-01.factory.internal", 8193)] // scheme-less hostname
    public void HostAddress_parses_valid(string input, string host, int port)
    {
        var parsed = FocasHostAddress.TryParse(input);
        parsed.ShouldNotBeNull();
        parsed.Host.ShouldBe(host);
        parsed.Port.ShouldBe(port);
    }

    /// <summary>Verifies FocasHostAddress.TryParse rejects invalid addresses.</summary>
    /// <param name="input">The input address string to test for rejection.</param>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("http://10.0.0.5/")]
    [InlineData("focas:10.0.0.5:8193")]          // missing //
    [InlineData("focas://")]                     // empty body
    [InlineData("focas://10.0.0.5:0")]           // port 0
    [InlineData("focas://10.0.0.5:65536")]       // port out of range
    [InlineData("focas://10.0.0.5:abc")]         // non-numeric port
    public void HostAddress_rejects_invalid(string? input)
    {
        FocasHostAddress.TryParse(input).ShouldBeNull();
    }

    /// <summary>Verifies FocasHostAddress.ToString strips the default port.</summary>
    [Fact]
    public void HostAddress_ToString_strips_default_port()
    {
        new FocasHostAddress("10.0.0.5", 8193).ToString().ShouldBe("focas://10.0.0.5");
        new FocasHostAddress("10.0.0.5", 12345).ToString().ShouldBe("focas://10.0.0.5:12345");
    }

    // ---- FocasAddress ----

    /// <summary>Verifies FocasAddress.TryParse correctly parses PMC address forms.</summary>
    /// <param name="input">The input address string to parse.</param>
    /// <param name="kind">The expected FocasAreaKind after parsing.</param>
    /// <param name="letter">The expected PMC letter after parsing.</param>
    /// <param name="num">The expected number after parsing.</param>
    /// <param name="bit">The expected bit index after parsing.</param>
    [Theory]
    [InlineData("X0.0", FocasAreaKind.Pmc, "X", 0, 0)]
    [InlineData("X0", FocasAreaKind.Pmc, "X", 0, null)]
    [InlineData("Y10", FocasAreaKind.Pmc, "Y", 10, null)]
    [InlineData("F20.3", FocasAreaKind.Pmc, "F", 20, 3)]
    [InlineData("G54", FocasAreaKind.Pmc, "G", 54, null)]
    [InlineData("R100", FocasAreaKind.Pmc, "R", 100, null)]
    [InlineData("D200", FocasAreaKind.Pmc, "D", 200, null)]
    [InlineData("C300", FocasAreaKind.Pmc, "C", 300, null)]
    [InlineData("K400", FocasAreaKind.Pmc, "K", 400, null)]
    [InlineData("A500", FocasAreaKind.Pmc, "A", 500, null)]
    [InlineData("E600", FocasAreaKind.Pmc, "E", 600, null)]
    [InlineData("T50.4", FocasAreaKind.Pmc, "T", 50, 4)]
    public void Address_parses_PMC_forms(string input, FocasAreaKind kind, string letter, int num, int? bit)
    {
        var a = FocasAddress.TryParse(input);
        a.ShouldNotBeNull();
        a.Kind.ShouldBe(kind);
        a.PmcLetter.ShouldBe(letter);
        a.Number.ShouldBe(num);
        a.BitIndex.ShouldBe(bit);
    }

    /// <summary>Verifies FocasAddress.TryParse correctly parses parameter address forms.</summary>
    /// <param name="input">The input address string to parse.</param>
    /// <param name="kind">The expected FocasAreaKind after parsing.</param>
    /// <param name="num">The expected number after parsing.</param>
    /// <param name="bit">The expected bit index after parsing.</param>
    [Theory]
    [InlineData("PARAM:1020", FocasAreaKind.Parameter, 1020, null)]
    [InlineData("PARAM:1815/0", FocasAreaKind.Parameter, 1815, 0)]
    [InlineData("PARAM:1815/31", FocasAreaKind.Parameter, 1815, 31)]
    public void Address_parses_parameter_forms(string input, FocasAreaKind kind, int num, int? bit)
    {
        var a = FocasAddress.TryParse(input);
        a.ShouldNotBeNull();
        a.Kind.ShouldBe(kind);
        a.PmcLetter.ShouldBeNull();
        a.Number.ShouldBe(num);
        a.BitIndex.ShouldBe(bit);
    }

    /// <summary>Verifies FocasAddress.TryParse correctly parses macro address forms.</summary>
    /// <param name="input">The input address string to parse.</param>
    /// <param name="kind">The expected FocasAreaKind after parsing.</param>
    /// <param name="num">The expected number after parsing.</param>
    [Theory]
    [InlineData("MACRO:100", FocasAreaKind.Macro, 100)]
    [InlineData("MACRO:500", FocasAreaKind.Macro, 500)]
    public void Address_parses_macro_forms(string input, FocasAreaKind kind, int num)
    {
        var a = FocasAddress.TryParse(input);
        a.ShouldNotBeNull();
        a.Kind.ShouldBe(kind);
        a.Number.ShouldBe(num);
        a.BitIndex.ShouldBeNull();
    }

    /// <summary>Verifies FocasAddress.TryParse rejects invalid address forms.</summary>
    /// <param name="input">The input address string to test for rejection.</param>
    [Theory]
    [InlineData(null)]
    [InlineData("")]
    [InlineData("   ")]
    [InlineData("Z0")]                   // unknown PMC letter
    [InlineData("X")]                    // missing number
    [InlineData("X-1")]                  // negative number
    [InlineData("Xabc")]                 // non-numeric
    [InlineData("X0.8")]                 // bit out of range (0-7)
    [InlineData("X0.-1")]                // negative bit
    [InlineData("PARAM:")]               // missing number
    [InlineData("PARAM:1815/32")]        // bit out of range (0-31)
    [InlineData("MACRO:abc")]            // non-numeric
    public void Address_rejects_invalid_forms(string? input)
    {
        FocasAddress.TryParse(input).ShouldBeNull();
    }

    /// <summary>Verifies FocasAddress.Canonical roundtrips correctly.</summary>
    /// <param name="input">The input address string to roundtrip.</param>
    [Theory]
    [InlineData("X0.0")]
    [InlineData("R100")]
    [InlineData("F20.3")]
    [InlineData("PARAM:1020")]
    [InlineData("PARAM:1815/0")]
    [InlineData("MACRO:100")]
    public void Address_Canonical_roundtrips(string input)
    {
        var parsed = FocasAddress.TryParse(input);
        parsed.ShouldNotBeNull();
        parsed.Canonical.ShouldBe(input);
    }

    // ---- FocasDataType ----

    /// <summary>Verifies data type mapping covers all atomic FOCAS types.</summary>
    [Fact]
    public void DataType_mapping_covers_atomic_focas_types()
    {
        FocasDataType.Bit.ToDriverDataType().ShouldBe(DriverDataType.Boolean);
        FocasDataType.Int16.ToDriverDataType().ShouldBe(DriverDataType.Int32);
        FocasDataType.Int32.ToDriverDataType().ShouldBe(DriverDataType.Int32);
        FocasDataType.Float32.ToDriverDataType().ShouldBe(DriverDataType.Float32);
        FocasDataType.Float64.ToDriverDataType().ShouldBe(DriverDataType.Float64);
        FocasDataType.String.ToDriverDataType().ShouldBe(DriverDataType.String);
    }

    // ---- FocasStatusMapper ----

    /// <summary>Verifies status mapper covers all known FOCAS return codes.</summary>
    /// <param name="ret">The FOCAS return code to map.</param>
    /// <param name="expected">The expected mapped status code.</param>
    [Theory]
    [InlineData(0, FocasStatusMapper.Good)]
    [InlineData(3, FocasStatusMapper.BadOutOfRange)]         // EW_NUMBER
    [InlineData(4, FocasStatusMapper.BadOutOfRange)]         // EW_LENGTH
    [InlineData(5, FocasStatusMapper.BadNotWritable)]        // EW_PROT
    [InlineData(6, FocasStatusMapper.BadNotSupported)]       // EW_NOOPT
    [InlineData(8, FocasStatusMapper.BadNodeIdUnknown)]      // EW_DATA
    [InlineData(-1, FocasStatusMapper.BadDeviceFailure)]     // EW_BUSY
    [InlineData(-8, FocasStatusMapper.BadInternalError)]     // EW_HANDLE
    [InlineData(-16, FocasStatusMapper.BadCommunicationError)] // EW_SOCKET
    [InlineData(999, FocasStatusMapper.BadCommunicationError)] // unknown → generic
    public void StatusMapper_covers_known_focas_returns(int ret, uint expected)
    {
        FocasStatusMapper.MapFocasReturn(ret).ShouldBe(expected);
    }

    // ---- FocasDriver ----

    /// <summary>Verifies FocasDriver initializes with correct type and ID.</summary>
    [Fact]
    public void DriverType_is_FOCAS()
    {
        var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
        drv.DriverType.ShouldBe("FOCAS");
        drv.DriverInstanceId.ShouldBe("drv-1");
    }

    /// <summary>
    ///     Verifies FOCAS opts into retry-until-stable post-connect re-discovery — its
    ///     FixedTree subtree is populated asynchronously by a background loop a couple of
    ///     seconds after connect, so a single DiscoverAsync pass would miss it.
    /// </summary>
    [Fact]
    public void RediscoverPolicy_is_UntilStable()
    {
        var drv = new FocasDriver(new FocasDriverOptions(), "drv-1");
        drv.RediscoverPolicy.ShouldBe(DiscoveryRediscoverPolicy.UntilStable);
    }

    /// <summary>Verifies InitializeAsync parses device addresses correctly.</summary>
    [Fact]
    public async Task InitializeAsync_parses_device_addresses()
    {
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices =
            [
                new FocasDeviceOptions("focas://10.0.0.5:8193"),
                new FocasDeviceOptions("focas://10.0.0.6:12345", DeviceName: "CNC-2"),
            ],
        }, "drv-1");

        await drv.InitializeAsync("{}", CancellationToken.None);

        drv.DeviceCount.ShouldBe(2);
        drv.GetDeviceState("focas://10.0.0.5:8193")!.ParsedAddress.Port.ShouldBe(8193);
        drv.GetDeviceState("focas://10.0.0.6:12345")!.Options.DeviceName.ShouldBe("CNC-2");
    }

    /// <summary>Verifies InitializeAsync faults on malformed addresses.</summary>
    [Fact]
    public async Task InitializeAsync_malformed_address_faults()
    {
        // A non-focas:// URI scheme is rejected by TryParse (a bare "{ip}[:{port}]" is now
        // tolerated, so the malformed case must carry a foreign scheme).
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("http://10.0.0.5/")],
        }, "drv-1");

        await Should.ThrowAsync<InvalidOperationException>(
            () => drv.InitializeAsync("{}", CancellationToken.None));
        drv.GetHealth().State.ShouldBe(DriverState.Faulted);
    }

    /// <summary>Verifies ShutdownAsync clears all devices.</summary>
    [Fact]
    public async Task ShutdownAsync_clears_devices()
    {
        var drv = new FocasDriver(new FocasDriverOptions
        {
            Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
            Probe = new FocasProbeOptions { Enabled = false },
        }, "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.ShutdownAsync(CancellationToken.None);
        drv.DeviceCount.ShouldBe(0);
        drv.GetHealth().State.ShouldBe(DriverState.Unknown);
    }

    // ---- UnimplementedFocasClientFactory ----

    /// <summary>Verifies UnimplementedFocasClientFactory throws on Create.</summary>
    [Fact]
    public void Unimplemented_factory_throws_on_Create_with_config_pointer()
    {
        var factory = new UnimplementedFocasClientFactory();
        var ex = Should.Throw<NotSupportedException>(() => factory.Create());
        ex.Message.ShouldContain("wire");
        ex.Message.ShouldContain("docs/drivers/FOCAS.md");
    }

    /// <summary>
    ///     Verifies a driver configured with the unimplemented backend faults at
    ///     InitializeAsync — not lazily on the first read. The operator must get an
    ///     actionable error immediately rather than a phantom-Healthy driver that
    ///     fails every read silently.
    /// </summary>
    [Fact]
    public async Task Unimplemented_backend_faults_at_InitializeAsync_not_on_first_read()
    {
        var drv = new FocasDriver(
            new FocasDriverOptions { Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")] },
            "drv-unimpl",
            clientFactory: new UnimplementedFocasClientFactory());

        var ex = await Should.ThrowAsync<NotSupportedException>(
            () => drv.InitializeAsync("{}", CancellationToken.None));

        ex.Message.ShouldContain("unimplemented");
        drv.GetHealth().State.ShouldBe(DriverState.Faulted);
    }

    /// <summary>
    ///     Verifies WireFocasClientFactory.EnsureUsable is a no-op — the wire backend
    ///     must still initialize clean (no false-positive faults from the probe).
    /// </summary>
    [Fact]
    public void Wire_factory_EnsureUsable_does_not_throw()
    {
        var factory = new Wire.WireFocasClientFactory();
        Should.NotThrow(() => factory.EnsureUsable());
    }
}
