using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
using ZB.MOM.WW.HistorianGateway.Contracts.Grpc;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests;

public sealed class GatewayTagProvisionerTests
{
    private static GatewayTagProvisioner Provisioner(FakeHistorianGatewayClient fake) =>
        new(fake, NullLogger<GatewayTagProvisioner>.Instance);

    [Fact]
    public async Task Ensures_numeric_tags_with_mapped_type()
    {
        var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
        var p = Provisioner(fake);
        var reqs = new[]
        {
            new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, "degC", "Temp"),
            new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null),
        };

        var result = await p.EnsureTagsAsync(reqs, TestContext.Current.CancellationToken);

        Assert.NotNull(fake.LastEnsureDefinitions);
        Assert.Equal(2, fake.LastEnsureDefinitions!.Count);
        Assert.Equal(HistorianDataType.Float, fake.LastEnsureDefinitions[0].DataType);
        Assert.Equal(HistorianDataType.Int2, fake.LastEnsureDefinitions[1].DataType);
        Assert.Equal(2, result.Requested);
        Assert.Equal(0, result.Skipped);
    }

    /// <summary>
    /// Pins the Boolean→Int2 provisioning semantics at TWO levels. Boolean maps to <c>Int2</c> because
    /// the historian's <c>Int1</c> analog-tag creation path is server-degenerate — <c>EnsureTags(Int1)</c>
    /// stores an unusable stub (issue #441 / HistorianGateway #11) — so Boolean historizes as a small 0/1
    /// integer. Asserting BOTH the mapped type AND <c>HasDataType</c> compile-pins the package floor (the
    /// <c>HasDataType</c>/<c>ClearDataType</c> members exist only on the 0.2.0+ proto3-optional contract —
    /// a downgrade breaks the build) and assert-pins that the provisioner always sends explicit presence
    /// (a future contract change that stopped sending it would default the tag to the SDK's Float and
    /// break this assert). This is a pin, not a fix — it passes on current code.
    /// </summary>
    [Fact]
    public async Task Boolean_definition_carries_explicit_Int2_presence()
    {
        var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
        var p = Provisioner(fake);

        await p.EnsureTagsAsync(
            new[] { new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null) },
            TestContext.Current.CancellationToken);

        var defs = fake.LastEnsureDefinitions!;
        Assert.Single(defs);
        Assert.Equal(HistorianDataType.Int2, defs[0].DataType);
        // proto3-optional presence witness — explicit presence is what flips the gateway from its
        // SDK-default Float to the requested Int2. Without this, a Boolean tag would provision as Float.
        Assert.True(defs[0].HasDataType);
    }

    [Fact]
    public async Task Definition_carries_a_positive_storage_rate()
    {
        // Regression guard (R2-06 live gate): the gateway maps StorageRateMs straight to the AVEVA SDK's
        // storageRateMs, which requires a positive value — the proto uint32 default of 0 makes EnsureTags
        // throw ArgumentOutOfRangeException ("Storage rate must be > 0 ms"), silently failing EVERY
        // historized-tag provision. A non-zero rate must always be stamped.
        var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
        var p = Provisioner(fake);

        await p.EnsureTagsAsync(
            new[] { new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null) },
            TestContext.Current.CancellationToken);

        var defs = fake.LastEnsureDefinitions!;
        Assert.Single(defs);
        Assert.True(defs[0].StorageRateMs > 0, "provisioned tags must carry a positive storage rate (0 fails EnsureTags).");
    }

    [Fact]
    public async Task Maps_metadata_and_coalesces_null_metadata_to_empty()
    {
        var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
        var p = Provisioner(fake);

        await p.EnsureTagsAsync(
            new[]
            {
                new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, "degC", "Temp"),
                new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null),
            },
            TestContext.Current.CancellationToken);

        var defs = fake.LastEnsureDefinitions!;
        Assert.Equal("Pump1.Temp", defs[0].TagName);
        Assert.Equal("degC", defs[0].EngineeringUnit);
        Assert.Equal("Temp", defs[0].Description);
        // Proto string fields are non-nullable — null metadata must coalesce to empty.
        Assert.Equal(string.Empty, defs[1].EngineeringUnit);
        Assert.Equal(string.Empty, defs[1].Description);
    }

    [Fact]
    public async Task Deferred_types_are_skipped_not_sent()
    {
        var fake = new FakeHistorianGatewayClient { EnsureTagsResult = new TagOperationResults() };
        var p = Provisioner(fake);

        var result = await p.EnsureTagsAsync(
            new[] { new HistorianTagProvisionRequest("Pump1.Name", DriverDataType.String, null, null) },
            TestContext.Current.CancellationToken);

        // String is deferred → never built into a definition, so the empty batch skips the gateway
        // round-trip entirely (the call is never made).
        Assert.Equal(0, fake.EnsureTagsCallCount);
        Assert.Equal(1, result.Requested);
        Assert.Equal(1, result.Skipped);
    }

    [Fact]
    public async Task Cancellation_is_quiet_and_not_misreported()
    {
        // A plain shutdown cancellation must not throw and must not be a scary Warning. The unsent
        // batch is counted as Failed (it didn't land) but handled quietly — non-blocking contract.
        using var cts = new CancellationTokenSource();
        await cts.CancelAsync();
        var fake = new FakeHistorianGatewayClient { EnsureTagsThrows = new OperationCanceledException() };
        var p = Provisioner(fake);

        var result = await p.EnsureTagsAsync(
            new[] { new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null) },
            cts.Token);

        Assert.Equal(1, result.Failed);   // counted, not thrown
        Assert.Equal(0, result.Ensured);
    }

    [Fact]
    public async Task Gateway_failure_is_swallowed_and_counted_not_thrown()
    {
        var fake = new FakeHistorianGatewayClient { EnsureTagsThrows = new Exception("boom") };
        var p = Provisioner(fake);

        var result = await p.EnsureTagsAsync(
            new[] { new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null) },
            TestContext.Current.CancellationToken);

        Assert.Equal(1, result.Failed); // non-blocking: no throw
    }

    [Fact]
    public async Task Ensured_count_reflects_successful_results()
    {
        var fake = new FakeHistorianGatewayClient
        {
            EnsureTagsResult = new TagOperationResults
            {
                Results =
                {
                    new TagOperationResult { Name = "Pump1.Temp", Success = true },
                    new TagOperationResult { Name = "Pump1.Run", Success = false, Error = "x" },
                },
            },
        };
        var p = Provisioner(fake);

        var result = await p.EnsureTagsAsync(
            new[]
            {
                new HistorianTagProvisionRequest("Pump1.Temp", DriverDataType.Float32, null, null),
                new HistorianTagProvisionRequest("Pump1.Run", DriverDataType.Boolean, null, null),
            },
            TestContext.Current.CancellationToken);

        Assert.Equal(2, result.Requested);
        Assert.Equal(1, result.Ensured);
        Assert.Equal(0, result.Skipped);
        Assert.Equal(1, result.Failed);
    }
}
