using Shouldly;
using Xunit;

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

[Trait("Category", "Unit")]
public sealed class AbCipUdtReadPlannerTests
{
    private const string Device = "ab://10.0.0.1/1,0";

    /// <summary>Verifies two members of the same UDT parent are grouped.</summary>
    [Fact]
    public void Groups_Two_Members_Of_The_Same_Udt_Parent()
    {
        var tags = BuildUdtTagMap(out var _);
        var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque" }, tags, enableDeclarationOnlyGrouping: true);

        plan.Groups.Count.ShouldBe(1);
        plan.Groups[0].ParentName.ShouldBe("Motor");
        plan.Groups[0].Members.Count.ShouldBe(2);
        plan.Fallbacks.Count.ShouldBe(0);
    }

    /// <summary>Verifies single member reference falls back to per-tag path.</summary>
    [Fact]
    public void Single_Member_Reference_Falls_Back_To_Per_Tag_Path()
    {
        // Reading just one member of a UDT gains nothing from grouping — one whole-UDT read
        // vs one member read is equivalent cost but more client-side work. Planner demotes.
        var tags = BuildUdtTagMap(out var _);
        var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed" }, tags, enableDeclarationOnlyGrouping: true);

        plan.Groups.ShouldBeEmpty();
        plan.Fallbacks.Count.ShouldBe(1);
        plan.Fallbacks[0].Reference.ShouldBe("Motor.Speed");
    }

    /// <summary>Verifies unknown references fall back without affecting groups.</summary>
    [Fact]
    public void Unknown_References_Fall_Back_Without_Affecting_Groups()
    {
        var tags = BuildUdtTagMap(out var _);
        var plan = AbCipUdtReadPlanner.Build(
            new[] { "Motor.Speed", "Motor.Torque", "DoesNotExist", "Motor.NonMember" }, tags, enableDeclarationOnlyGrouping: true);

        plan.Groups.Count.ShouldBe(1);
        plan.Groups[0].Members.Count.ShouldBe(2);
        plan.Fallbacks.Count.ShouldBe(2);
        plan.Fallbacks.ShouldContain(f => f.Reference == "DoesNotExist");
        plan.Fallbacks.ShouldContain(f => f.Reference == "Motor.NonMember");
    }

    /// <summary>Verifies atomic top-level tags fall back untouched.</summary>
    [Fact]
    public void Atomic_Top_Level_Tag_Falls_Back_Untouched()
    {
        var tags = BuildUdtTagMap(out var _);
        tags = new Dictionary<string, AbCipTagDefinition>(tags, StringComparer.OrdinalIgnoreCase)
        {
            ["PlainDint"] = new("PlainDint", Device, "PlainDint", AbCipDataType.DInt),
        };
        var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Speed", "Motor.Torque", "PlainDint" }, tags, enableDeclarationOnlyGrouping: true);

        plan.Groups.Count.ShouldBe(1);
        plan.Fallbacks.Count.ShouldBe(1);
        plan.Fallbacks[0].Reference.ShouldBe("PlainDint");
    }

    /// <summary>Verifies UDT with bool member does not group.</summary>
    [Fact]
    public void Udt_With_Bool_Member_Does_Not_Group()
    {
        // Any BOOL in the declared members disqualifies the group — offset rules for BOOL
        // can't be determined from declaration alone (Logix packs them into a hidden host
        // byte). Fallback path reads each member individually.
        var members = new[]
        {
            new AbCipStructureMember("Run", AbCipDataType.Bool),
            new AbCipStructureMember("Speed", AbCipDataType.DInt),
        };
        var parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure,
            Members: members);
        var tags = new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
        {
            ["Motor"] = parent,
            ["Motor.Run"] = new("Motor.Run", Device, "Motor.Run", AbCipDataType.Bool),
            ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
        };

        var plan = AbCipUdtReadPlanner.Build(new[] { "Motor.Run", "Motor.Speed" }, tags, enableDeclarationOnlyGrouping: true);

        plan.Groups.ShouldBeEmpty();
        plan.Fallbacks.Count.ShouldBe(2);
    }

    /// <summary>Verifies original indices are preserved for out-of-order batches.</summary>
    [Fact]
    public void Original_Indices_Preserved_For_Out_Of_Order_Batches()
    {
        var tags = BuildUdtTagMap(out var _);
        var plan = AbCipUdtReadPlanner.Build(
            new[] { "Other", "Motor.Speed", "DoesNotExist", "Motor.Torque" }, tags, enableDeclarationOnlyGrouping: true);

        // Motor.Speed was at index 1, Motor.Torque at 3 — must survive through the plan so
        // ReadAsync can write decoded values back at the right output slot.
        plan.Groups.ShouldHaveSingleItem();
        var group = plan.Groups[0];
        group.Members.ShouldContain(m => m.OriginalIndex == 1 && m.Definition.Name == "Motor.Speed");
        group.Members.ShouldContain(m => m.OriginalIndex == 3 && m.Definition.Name == "Motor.Torque");
        plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 0 && f.Reference == "Other");
        plan.Fallbacks.ShouldContain(f => f.OriginalIndex == 2 && f.Reference == "DoesNotExist");
    }

    private static Dictionary<string, AbCipTagDefinition> BuildUdtTagMap(out AbCipTagDefinition parent)
    {
        var members = new[]
        {
            new AbCipStructureMember("Speed", AbCipDataType.DInt),
            new AbCipStructureMember("Torque", AbCipDataType.Real),
        };
        parent = new AbCipTagDefinition("Motor", Device, "Motor", AbCipDataType.Structure, Members: members);
        return new Dictionary<string, AbCipTagDefinition>(StringComparer.OrdinalIgnoreCase)
        {
            ["Motor"] = parent,
            ["Motor.Speed"] = new("Motor.Speed", Device, "Motor.Speed", AbCipDataType.DInt),
            ["Motor.Torque"] = new("Motor.Torque", Device, "Motor.Torque", AbCipDataType.Real),
        };
    }
}
