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

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

/// <summary>
///     Phase 4c (Task 6) — 1-D array support for AbCip. Covers: discovery flips
///     <see cref="DriverAttributeInfo.IsArray"/> / <see cref="DriverAttributeInfo.ArrayDim"/>
///     for an array atomic tag + an array UDT member; the read path returns a typed CLR array
///     boxed as <see cref="object"/>; and the v3 tag mapper threads <c>arrayLength</c> from the
///     TagConfig into the definition's element count so an <c>isArray</c> tag reads the whole array.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipArrayTests
{
    private const string Device = "ab://10.0.0.5/1,0";

    private static (AbCipDriver drv, FakeAbCipTagFactory factory) NewDriver(params AbCipTagDefinition[] tags)
    {
        var factory = new FakeAbCipTagFactory();
        var opts = new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(Device)],
            RawTags = AbCipRawTags.From(tags),
        };
        var drv = new AbCipDriver(opts, "drv-array", factory);
        return (drv, factory);
    }

    // ---- Discovery: IsArray / ArrayDim flips ----

    /// <summary>An atomic pre-declared tag with ElementCount > 1 discovers as a 1-D array.</summary>
    [Fact]
    public async Task PreDeclared_array_tag_discovers_as_IsArray_with_ArrayDim()
    {
        var builder = new RecordingBuilder();
        var (drv, _) = NewDriver(
            new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 10, IsArray: true),
            new AbCipTagDefinition("Single", Device, "Single", AbCipDataType.DInt));
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.DiscoverAsync(builder, CancellationToken.None);

        var arr = builder.Variables.Single(v => v.BrowseName == "Recipe").Info;
        arr.IsArray.ShouldBeTrue();
        arr.ArrayDim.ShouldBe(10u);

        var scalar = builder.Variables.Single(v => v.BrowseName == "Single").Info;
        scalar.IsArray.ShouldBeFalse();
        scalar.ArrayDim.ShouldBeNull();
    }

    /// <summary>A UDT member with ElementCount > 1 discovers as a 1-D array variable.</summary>
    [Fact]
    public async Task Udt_array_member_discovers_as_IsArray_with_ArrayDim()
    {
        var builder = new RecordingBuilder();
        var (drv, _) = NewDriver(
            new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
                Members:
                [
                    new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true),
                    new AbCipStructureMember("Speed", AbCipDataType.DInt),
                ]));
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.DiscoverAsync(builder, CancellationToken.None);

        var member = builder.Variables.Single(v => v.BrowseName == "Setpoints").Info;
        member.IsArray.ShouldBeTrue();
        member.ArrayDim.ShouldBe(4u);

        var scalarMember = builder.Variables.Single(v => v.BrowseName == "Speed").Info;
        scalarMember.IsArray.ShouldBeFalse();
        scalarMember.ArrayDim.ShouldBeNull();
    }

    // ---- Read: typed CLR array ----

    /// <summary>An array DInt tag reads as a boxed int[] of the configured element count.</summary>
    [Fact]
    public async Task Array_DInt_read_returns_typed_int_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 11, 22, 33, 44 });

        var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);

        snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
        var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
        value.ShouldBe([11, 22, 33, 44]);
        // libplctag element count must be threaded to the runtime params.
        factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
    }

    /// <summary>An array Real tag reads as a boxed float[].</summary>
    [Fact]
    public async Task Array_Real_read_returns_typed_float_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Floats", Device, "Floats", AbCipDataType.Real, ElementCount: 3, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f });

        var snapshots = await drv.ReadAsync(["Floats"], CancellationToken.None);

        var value = snapshots.Single().Value.ShouldBeOfType<float[]>();
        value.ShouldBe([1.5f, 2.5f, 3.5f]);
    }

    /// <summary>An array Bool tag reads as a boxed bool[].</summary>
    [Fact]
    public async Task Array_Bool_read_returns_typed_bool_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Flags", Device, "Flags", AbCipDataType.Bool, ElementCount: 3, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new bool[] { true, false, true });

        var snapshots = await drv.ReadAsync(["Flags"], CancellationToken.None);

        var value = snapshots.Single().Value.ShouldBeOfType<bool[]>();
        value.ShouldBe([true, false, true]);
    }

    /// <summary>An array String tag reads as a boxed string[].</summary>
    [Fact]
    public async Task Array_String_read_returns_typed_string_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Names", Device, "Names", AbCipDataType.String, ElementCount: 2, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new string[] { "a", "b" });

        var snapshots = await drv.ReadAsync(["Names"], CancellationToken.None);

        var value = snapshots.Single().Value.ShouldBeOfType<string[]>();
        value.ShouldBe(["a", "b"]);
    }

    /// <summary>A scalar tag (ElementCount 1) is unaffected — still a boxed scalar, not an array.</summary>
    [Fact]
    public async Task Scalar_read_path_unchanged_for_element_count_one()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };

        var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);

        snapshots.Single().Value.ShouldBe(4200);
        snapshots.Single().Value.ShouldNotBeOfType<int[]>();
    }

    /// <summary>
    ///     Driver.AbCip-016 — a DECLARED UDT member that is a 1-D array (<c>Setpoints : REAL[4]</c>)
    ///     must READ as a typed CLR array, matching the array node it discovers as. The member fan-out
    ///     in <c>InitializeAsync</c> threads the member's <c>ElementCount</c> / <c>IsArray</c> into the
    ///     fanned-out runtime definition.
    /// </summary>
    [Fact]
    public async Task Declared_udt_array_member_reads_as_typed_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
                Members:
                [
                    new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4, IsArray: true),
                    new AbCipStructureMember("Speed", AbCipDataType.DInt),
                ]));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new float[] { 1.5f, 2.5f, 3.5f, 4.5f });

        var snapshots = await drv.ReadAsync(["Motor.Setpoints"], CancellationToken.None);

        snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
        var value = snapshots.Single().Value.ShouldBeOfType<float[]>();
        value.ShouldBe([1.5f, 2.5f, 3.5f, 4.5f]);
        // The fanned-out member runtime must thread the member's element count to libplctag.
        factory.Tags["Motor.Setpoints"].CreationParams.ElementCount.ShouldBe(4);
        factory.Tags["Motor.Setpoints"].CreationParams.IsArray.ShouldBeTrue();
    }

    // ---- Mapper: arrayLength threading ----

    /// <summary>An authored array tag reads as a typed CLR array end-to-end.</summary>
    [Fact]
    public async Task Authored_array_tag_reads_as_a_typed_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 4, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 7, 8, 9, 10 });

        var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);

        snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
        var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
        value.ShouldBe([7, 8, 9, 10]);
        factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(4);
    }

    /// <summary>The mapper threads arrayLength into the definition's ElementCount and sets IsArray.</summary>
    [Fact]
    public void Mapper_threads_arrayLength_into_ElementCount()
    {
        var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":8}""";
        AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
        def!.ElementCount.ShouldBe(8);
        def.IsArray.ShouldBeTrue();
    }

    /// <summary>A non-array TagConfig defaults ElementCount to 1 (scalar) and IsArray false.</summary>
    [Fact]
    public void Mapper_defaults_ElementCount_to_one_when_not_an_array()
    {
        var json = """{"tagPath":"Recipe","dataType":"DInt"}""";
        AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
        def!.ElementCount.ShouldBe(1);
        def.IsArray.ShouldBeFalse();
    }

    /// <summary>
    ///     Review finding I-2 — <c>isArray:true</c> with NO <c>arrayLength</c> is a DEGENERATE input
    ///     that must map as SCALAR (IsArray false, ElementCount 1), matching every other driver.
    /// </summary>
    [Fact]
    public void Mapper_isArray_true_without_arrayLength_maps_as_scalar()
    {
        var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true}""";
        AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
        def!.IsArray.ShouldBeFalse();
        def.ElementCount.ShouldBe(1);
    }

    /// <summary>
    ///     Review finding I-2 — <c>isArray:true</c> with an invalid (zero) <c>arrayLength</c> also
    ///     maps as SCALAR, consistent with the cross-driver rule.
    /// </summary>
    [Fact]
    public void Mapper_isArray_true_with_zero_arrayLength_maps_as_scalar()
    {
        var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":0}""";
        AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
        def!.IsArray.ShouldBeFalse();
        def.ElementCount.ShouldBe(1);
    }

    /// <summary>
    ///     Review finding I-1 — a 1-element array (<c>isArray:true, arrayLength:1</c>) is a valid
    ///     1-element array, NOT a scalar: the mapper sets IsArray true and ElementCount 1.
    /// </summary>
    [Fact]
    public void Mapper_treats_isArray_with_arrayLength_one_as_a_one_element_array()
    {
        var json = """{"tagPath":"Recipe","dataType":"DInt","isArray":true,"arrayLength":1}""";
        AbCipTagDefinitionFactory.FromTagConfig(json, "line1/eq/tag", out var def).ShouldBeTrue();
        def!.IsArray.ShouldBeTrue();
        def.ElementCount.ShouldBe(1);
    }

    /// <summary>
    ///     Review finding I-1 — a 1-element array tag must DISCOVER as a [1] array node (IsArray +
    ///     ArrayDim 1), matching the foundation's materialisation, not as a scalar.
    /// </summary>
    [Fact]
    public async Task Authored_one_element_array_discovers_as_one_element_array()
    {
        var builder = new RecordingBuilder();
        var (drv, _) = NewDriver(
            new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.DiscoverAsync(builder, CancellationToken.None);

        var arr = builder.Variables.Single().Info;
        arr.IsArray.ShouldBeTrue();
        arr.ArrayDim.ShouldBe(1u);
    }

    /// <summary>
    ///     Review finding I-1 — a 1-element array tag reads a 1-ELEMENT typed array, NOT a scalar.
    /// </summary>
    [Fact]
    public async Task Authored_one_element_array_reads_as_one_element_array()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Recipe", Device, "Recipe", AbCipDataType.DInt, ElementCount: 1, IsArray: true));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new ArrayFakeAbCipTag(p, new int[] { 99 });

        var snapshots = await drv.ReadAsync(["Recipe"], CancellationToken.None);

        snapshots.Single().StatusCode.ShouldBe(AbCipStatusMapper.Good);
        var value = snapshots.Single().Value.ShouldBeOfType<int[]>();
        value.ShouldBe([99]);
        // A 1-element array still threads elem_count 1 to libplctag.
        factory.Tags["Recipe"].CreationParams.ElementCount.ShouldBe(1);
    }

    /// <summary>
    ///     Regression — a genuinely scalar tag (<c>isArray:false</c>) reads a boxed scalar via
    ///     <see cref="IAbCipTagRuntime.DecodeValue"/>, never an array.
    /// </summary>
    [Fact]
    public async Task Scalar_tag_reads_as_scalar()
    {
        var (drv, factory) = NewDriver(
            new AbCipTagDefinition("Speed", Device, "Speed", AbCipDataType.DInt));
        await drv.InitializeAsync("{}", CancellationToken.None);
        factory.Customise = p => new FakeAbCipTag(p) { Value = 4200 };

        var snapshots = await drv.ReadAsync(["Speed"], CancellationToken.None);

        snapshots.Single().Value.ShouldBe(4200);
        snapshots.Single().Value.ShouldNotBeOfType<int[]>();
    }

    // ---- helpers ----

    /// <summary>Minimal <see cref="IAddressSpaceBuilder"/> recorder for the discovery assertions.</summary>
    private sealed class RecordingBuilder : IAddressSpaceBuilder
    {
        public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
        public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();

        public IAddressSpaceBuilder Folder(string browseName, string displayName)
        { Folders.Add((browseName, displayName)); return this; }

        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
        { Variables.Add((browseName, info)); return new Handle(info.FullName); }

        public void AddProperty(string _, DriverDataType __, object? ___) { }

        private sealed class Handle(string fullRef) : IVariableHandle
        {
            public string FullReference => fullRef;
            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
        }

        private sealed class NullSink : IAlarmConditionSink
        {
            public void OnTransition(AlarmEventArgs args) { }
        }
    }
}
