using System.Buffers.Binary;
using System.Runtime.CompilerServices;
using System.Text;
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;

[Trait("Category", "Unit")]
public sealed class AbCipDriverDiscoveryTests
{
    /// <summary>Verifies that pre-declared tags emit as variables under device folder.</summary>
    [Fact]
    public async Task PreDeclared_tags_emit_as_variables_under_device_folder()
    {
        var builder = new RecordingBuilder();
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0", DeviceName: "Line1-PLC")],
            RawTags = AbCipRawTags.From(
                new AbCipTagDefinition("Speed", "ab://10.0.0.5/1,0", "Motor1.Speed", AbCipDataType.DInt),
                new AbCipTagDefinition("Temperature", "ab://10.0.0.5/1,0", "T", AbCipDataType.Real, Writable: false)),
        }, "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Folders.ShouldContain(f => f.BrowseName == "AbCip");
        builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0" && f.DisplayName == "Line1-PLC");
        builder.Variables.Count.ShouldBe(2);
        builder.Variables.Single(v => v.BrowseName == "Speed").Info.SecurityClass.ShouldBe(SecurityClassification.Operate);
        builder.Variables.Single(v => v.BrowseName == "Temperature").Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
    }

    /// <summary>Verifies that device folder display name falls back to host address when not provided.</summary>
    [Fact]
    public async Task Device_folder_displayname_falls_back_to_host_address()
    {
        var builder = new RecordingBuilder();
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], // no DeviceName
        }, "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Folders.ShouldContain(f => f.BrowseName == "ab://10.0.0.5/1,0"
            && f.DisplayName == "ab://10.0.0.5/1,0");
    }

    /// <summary>Verifies that pre-declared system tags are filtered out.</summary>
    [Fact]
    public async Task PreDeclared_system_tags_are_filtered_out()
    {
        var builder = new RecordingBuilder();
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            RawTags = AbCipRawTags.From(
                new AbCipTagDefinition("__DEFVAL_X", "ab://10.0.0.5/1,0", "__DEFVAL_X", AbCipDataType.DInt),
                new AbCipTagDefinition("Routine:SomeRoutine", "ab://10.0.0.5/1,0", "R", AbCipDataType.DInt),
                new AbCipTagDefinition("UserTag", "ab://10.0.0.5/1,0", "U", AbCipDataType.DInt)),
        }, "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Variables.Select(v => v.BrowseName).ShouldBe(["UserTag"]);
    }

    /// <summary>Verifies that tags for mismatched devices are ignored.</summary>
    [Fact]
    public async Task Tags_for_mismatched_device_are_ignored()
    {
        var builder = new RecordingBuilder();
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            RawTags = AbCipRawTags.From(new AbCipTagDefinition("Orphan", "ab://10.0.0.99/1,0", "O", AbCipDataType.DInt)),
        }, "drv-1");
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Variables.ShouldBeEmpty();
    }

    /// <summary>Verifies that controller enumeration adds tags under Discovered folder.</summary>
    [Fact]
    public async Task Controller_enumeration_adds_tags_under_Discovered_folder()
    {
        var builder = new RecordingBuilder();
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("Pressure", null, AbCipDataType.Real, ReadOnly: false),
            new AbCipDiscoveredTag("StepIndex", ProgramScope: "MainProgram", AbCipDataType.DInt, ReadOnly: false));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Folders.ShouldContain(f => f.BrowseName == "Discovered");
        builder.Variables.Select(v => v.Info.FullName).ShouldContain("Pressure");
        builder.Variables.Select(v => v.Info.FullName).ShouldContain("Program:MainProgram.StepIndex");
    }

    /// <summary>
    ///     A controller-discovered UDT (a Structure tag) is expanded into a member sub-folder
    ///     with one Variable per atomic member, mirroring the pre-declared fan-out. A nested
    ///     struct member recurses into its own atomic leaves (dot-joined full names). The bogus
    ///     single <c>Structure → String</c> placeholder Variable must NOT be emitted.
    /// </summary>
    [Fact]
    public async Task Controller_discovered_UDT_expands_into_member_variables()
    {
        var builder = new RecordingBuilder();
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        // Seed the discovered-UDT shapes the driver fans out from. The top-level Motor1 shape
        // carries two atomic members (Speed/Real, Running/Bool) + a nested struct member
        // (Status) whose own shape (Code/DInt) is seeded under its member name.
        drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Motor1", new AbCipUdtShape(
            "MotorUdt", 16,
            [
                new AbCipUdtMember("Speed", 0, AbCipDataType.Real, ArrayLength: 1),
                new AbCipUdtMember("Running", 4, AbCipDataType.Bool, ArrayLength: 1),
                new AbCipUdtMember("Status", 8, AbCipDataType.Structure, ArrayLength: 1),
            ]));
        drv.SeedDiscoveredUdtShapeForTest("ab://10.0.0.5/1,0", "Status", new AbCipUdtShape(
            "StatusUdt", 4,
            [
                new AbCipUdtMember("Code", 0, AbCipDataType.DInt, ArrayLength: 1),
            ]));

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

        // A Motor1 sub-folder is created under the Discovered folder.
        builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
        // The bare Structure placeholder Variable is NOT emitted (no Motor1 String node).
        builder.Variables.ShouldNotContain(v => v.Info.FullName == "Motor1");

        var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
        byName.ShouldContainKey("Motor1.Speed");
        byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
        byName.ShouldContainKey("Motor1.Running");
        byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
        // Nested struct leaf — dot-joined full name + the nested member's atomic type.
        byName.ShouldContainKey("Motor1.Status.Code");
        byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
    }

    /// <summary>
    ///     PRODUCTION PATH (no test seam): a controller-discovered Structure tag carries its CIP
    ///     template instance id (surfaced by the Symbol Object decoder onto
    ///     <see cref="AbCipDiscoveredTag.TemplateInstanceId"/>). DiscoverAsync threads that id into
    ///     <c>FetchUdtShapeAsync</c>, which reads the Template Object off the controller via the
    ///     injected <see cref="IAbCipTemplateReader"/> and fans the top-level UDT out into atomic
    ///     member Variables — WITHOUT any call to <c>SeedDiscoveredUdtShapeForTest</c>. This proves the
    ///     production top-level expansion is functional, not inert.
    /// </summary>
    [Fact]
    public async Task Controller_discovered_UDT_top_level_expands_via_template_id_fetch_no_seam()
    {
        const string device = "ab://10.0.0.5/1,0";
        const uint motorTemplateId = 0x2A; // lower 12 bits of the struct symbol type

        var builder = new RecordingBuilder();
        // Discovered tag carries the template instance id — exactly what the real Symbol Object
        // decoder produces for a struct symbol.
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
                TemplateInstanceId: motorTemplateId));

        // Fake template reader keyed by id: a Read Template for motorTemplateId returns a real
        // Template Object blob with two atomic members (Speed/Real, Running/Bool). No seed seam.
        var templateReaderFactory = new IdKeyedTemplateReaderFactory
        {
            Templates =
            {
                [motorTemplateId] = BuildSimpleTemplate("MotorUdt", 8,
                    ("Speed", 0xCA, 0, 0),    // 0xCA = Real
                    ("Running", 0xC1, 0, 4)), // 0xC1 = Bool
            },
        };

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(device)],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        // The reader was actually consulted for the discovered tag's template id — the production
        // fetch path ran (not the seam).
        templateReaderFactory.LastTemplateId.ShouldBe(motorTemplateId);

        // The Motor1 sub-folder + atomic member leaves are emitted; the bogus Structure placeholder
        // Variable is NOT.
        builder.Folders.ShouldContain(f => f.BrowseName == "Motor1");
        builder.Variables.ShouldNotContain(v => v.Info.FullName == "Motor1");

        var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
        byName.ShouldContainKey("Motor1.Speed");
        byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
        byName.ShouldContainKey("Motor1.Running");
        byName["Motor1.Running"].DriverDataType.ShouldBe(DriverDataType.Boolean);
    }

    /// <summary>
    ///     PRODUCTION PATH (no test seam): a controller-discovered UDT whose Template Object carries
    ///     a NESTED struct member fans the nested member out into its own atomic leaves WITHOUT any
    ///     <c>SeedDiscoveredUdtShapeForTest</c>. The nested member block carries the nested UDT's
    ///     template instance id (low 12 bits, struct flag set); the decoder captures it as
    ///     <c>NestedTemplateId</c> and DiscoverAsync threads it into a real <c>FetchUdtShapeAsync</c>
    ///     for the nested id. Proves the nested fetch actually happened (reader consulted for the
    ///     nested id) and that the nested struct's leaf member is addressable.
    /// </summary>
    [Fact]
    public async Task Controller_discovered_UDT_nested_struct_expands_via_nested_template_id_fetch_no_seam()
    {
        const string device = "ab://10.0.0.5/1,0";
        const uint motorTemplateId = 0x2A;   // parent UDT template id
        const uint statusTemplateId = 0x123; // nested UDT template id — exceeds a byte (12-bit proof)

        var builder = new RecordingBuilder();
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("Motor1", null, AbCipDataType.Structure, ReadOnly: false,
                TemplateInstanceId: motorTemplateId));

        // The parent template carries an atomic member (Speed/Real) + a NESTED struct member (Status)
        // whose member-info encodes the struct flag (0x8000) | the nested template id (0x123). The
        // nested template id is read off @udt/0x123 and yields one atomic leaf (Code/DInt).
        var templateReaderFactory = new IdKeyedTemplateReaderFactory
        {
            Templates =
            {
                [motorTemplateId] = BuildSimpleTemplate("MotorUdt", 12,
                    ("Speed", 0xCA, 0, 0),                                 // Real
                    ("Status", (ushort)(0x8000 | statusTemplateId), 0, 8)), // struct + nested id 0x123
                [statusTemplateId] = BuildSimpleTemplate("StatusUdt", 4,
                    ("Code", 0xC4, 0, 0)),                                 // DInt
            },
        };

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(device)],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory, templateReaderFactory: templateReaderFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        // The nested template id actually drove a fetch — the reader was consulted for it (the seam
        // would have left LastTemplateId at the parent id and never read the nested shape).
        templateReaderFactory.RequestedIds.ShouldContain(statusTemplateId);

        var byName = builder.Variables.ToDictionary(v => v.Info.FullName, v => v.Info);
        // The parent atomic leaf is emitted.
        byName.ShouldContainKey("Motor1.Speed");
        byName["Motor1.Speed"].DriverDataType.ShouldBe(DriverDataType.Float32);
        // The NESTED struct's atomic leaf is addressable — proving the nested fetch + fan-out ran.
        byName.ShouldContainKey("Motor1.Status.Code");
        byName["Motor1.Status.Code"].DriverDataType.ShouldBe(DriverDataType.Int32);
        // A Motor1.Status sub-folder was materialised for the nested struct.
        builder.Folders.ShouldContain(f => f.BrowseName == "Motor1.Status");
    }

    /// <summary>
    ///     A discovered Structure whose shape cannot be resolved degrades to the prior single
    ///     Variable behavior (no regression): one Variable under the Discovered folder keyed by
    ///     the bare struct name, never a broken member fan-out.
    /// </summary>
    [Fact]
    public async Task Controller_discovered_UDT_with_unresolvable_shape_degrades_to_single_variable()
    {
        var builder = new RecordingBuilder();
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("MysteryUdt", null, AbCipDataType.Structure, ReadOnly: false));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        // No shape seeded + no fake template reader → FetchUdtShapeAsync returns null → degrade.
        await drv.DiscoverAsync(builder, CancellationToken.None);

        var mystery = builder.Variables.Where(v => v.Info.FullName == "MysteryUdt").ToList();
        mystery.Count.ShouldBe(1);
        // No member fan-out folder for an unresolvable shape.
        builder.Folders.ShouldNotContain(f => f.BrowseName == "MysteryUdt");
    }

    /// <summary>
    ///     A nested struct member whose sub-shape resolves but yields NO emittable atomic leaf must
    ///     not leave an empty nested sub-folder in the browse tree — the folder is materialised lazily,
    ///     only once a leaf is actually emitted into it. Here <c>Empty</c> (a nested struct) has only
    ///     a further nested struct child whose own shape is unresolvable, so the whole <c>Empty</c>
    ///     branch produces no leaf and must produce no folder either; the sibling atomic leaf is kept.
    /// </summary>
    [Fact]
    public async Task Controller_discovered_UDT_does_not_leave_empty_nested_subfolder()
    {
        const string device = "ab://10.0.0.5/1,0";
        var builder = new RecordingBuilder();
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("Top", null, AbCipDataType.Structure, ReadOnly: false));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(device)],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        // Top has one atomic leaf (Ok) + a nested struct (Empty). Empty's shape resolves, but its
        // only member is a further nested struct (Ghost) whose shape is NOT seeded → unresolvable →
        // no leaf anywhere under Empty.
        drv.SeedDiscoveredUdtShapeForTest(device, "Top", new AbCipUdtShape("TopUdt", 8,
        [
            new AbCipUdtMember("Ok", 0, AbCipDataType.DInt, ArrayLength: 1),
            new AbCipUdtMember("Empty", 4, AbCipDataType.Structure, ArrayLength: 1),
        ]));
        drv.SeedDiscoveredUdtShapeForTest(device, "Empty", new AbCipUdtShape("EmptyUdt", 4,
        [
            new AbCipUdtMember("Ghost", 0, AbCipDataType.Structure, ArrayLength: 1),
        ]));

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

        // The Top folder + its atomic leaf are emitted.
        builder.Folders.ShouldContain(f => f.BrowseName == "Top");
        builder.Variables.Select(v => v.Info.FullName).ShouldContain("Top.Ok");
        // The Empty nested struct yields no leaf → no empty sub-folder is created for it.
        builder.Folders.ShouldNotContain(f => f.BrowseName == "Empty");
        builder.Variables.Select(v => v.Info.FullName).ShouldNotContain(n => n.StartsWith("Top.Empty", StringComparison.Ordinal));
    }

    /// <summary>
    ///     Nested-struct recursion is bounded by <c>MaxUdtDepth</c>: a member at the cap depth
    ///     is dropped rather than emitted. Here the chain L0.L1.L2.…  is seeded deeper than the
    ///     cap; the leaf beyond the cap must not appear.
    /// </summary>
    [Fact]
    public async Task Controller_discovered_UDT_drops_members_deeper_than_MaxUdtDepth()
    {
        var builder = new RecordingBuilder();
        const string device = "ab://10.0.0.5/1,0";
        var enumeratorFactory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("Deep", null, AbCipDataType.Structure, ReadOnly: false));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(device)],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: enumeratorFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        // Build a chain Deep -> N1 -> N2 -> ... -> N12 (struct each level) with an atomic Leaf at
        // every level. AbCipDriver.MaxUdtDepth bounds how deep the fan-out recurses.
        const int chainDepth = 12;
        drv.SeedDiscoveredUdtShapeForTest(device, "Deep", new AbCipUdtShape("Deep", 8,
            [
                new AbCipUdtMember("Leaf", 0, AbCipDataType.DInt, ArrayLength: 1),
                new AbCipUdtMember("N1", 4, AbCipDataType.Structure, ArrayLength: 1),
            ]));
        for (var i = 1; i <= chainDepth; i++)
        {
            var next = i < chainDepth ? new[] { new AbCipUdtMember($"N{i + 1}", 4, AbCipDataType.Structure, 1) } : [];
            var members = new List<AbCipUdtMember> { new($"Leaf", 0, AbCipDataType.DInt, 1) };
            members.AddRange(next);
            drv.SeedDiscoveredUdtShapeForTest(device, $"N{i}", new AbCipUdtShape($"N{i}", 8, members));
        }

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

        var names = builder.Variables.Select(v => v.Info.FullName).ToHashSet();
        // The top-level leaf is present.
        names.ShouldContain("Deep.Leaf");
        // A shallow nested leaf (well within the cap) is kept.
        names.ShouldContain("Deep.N1.Leaf");
        names.ShouldContain("Deep.N1.N2.Leaf");
        // A leaf at the deepest chain level (well beyond the depth cap of 8) must be dropped.
        var deepestPrefix = string.Join('.', Enumerable.Range(1, chainDepth).Select(i => $"N{i}"));
        names.ShouldNotContain($"Deep.{deepestPrefix}.Leaf");
        // No member full-name should carry more than MaxUdtDepth path segments below the parent
        // (parent + at most MaxUdtDepth dotted segments). The deepest emitted leaf proves the cap.
        var maxSegmentsBelowParent = names
            .Where(n => n.StartsWith("Deep.", StringComparison.Ordinal))
            .Select(n => n.Split('.').Length - 1) // segments after "Deep"
            .DefaultIfEmpty(0)
            .Max();
        maxSegmentsBelowParent.ShouldBeLessThanOrEqualTo(AbCipDriver.MaxUdtDepth);
    }

    /// <summary>
    ///     A member-path read goes end-to-end through the driver: an authored raw tag whose TagConfig
    ///     addresses a dotted member path (<c>Motor1.Status.Code</c>) is delivered as a
    ///     <see cref="RawTagEntry"/>, resolves by its RawPath, materialises a runtime on the device, and
    ///     decodes the member's atomic value. The libplctag tag name the driver builds equals the dotted
    ///     member path (the TagConfig's tagPath), independent of the RawPath identity.
    /// </summary>
    [Fact]
    public async Task Member_path_tag_reads_through_driver_as_member_atomic_type()
    {
        const string device = "ab://10.0.0.5/1,0";
        const string rawPath = "line1/motor1/status/code";
        // The authored TagConfig addresses the dotted member path (atomic Real here).
        const string tagConfig = "{\"tagPath\":\"Motor1.Status.Code\",\"dataType\":\"Real\"}";

        var tagFactory = new FakeAbCipTagFactory
        {
            // The driver creates the runtime keyed by the libplctag tag name = the dotted member
            // path; seed a Real value the read should surface.
            Customise = p => new FakeAbCipTag(p) { Value = p.TagName == "Motor1.Status.Code" ? 12.5f : null },
        };
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions(device)],
            RawTags = [new RawTagEntry(rawPath, tagConfig, WriteIdempotent: false, DeviceName: device)],
        }, "drv-1", tagFactory: tagFactory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        var results = await drv.ReadAsync([rawPath], CancellationToken.None);

        results.Count.ShouldBe(1);
        results[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
        results[0].Value.ShouldBe(12.5f);
        // The driver built a runtime under the dotted member tag name — confirming the member-path
        // read addresses the member directly.
        tagFactory.Tags.ShouldContainKey("Motor1.Status.Code");
    }

    /// <summary>Verifies that controller enumeration honours system tag hint and filter.</summary>
    [Fact]
    public async Task Controller_enumeration_honours_system_tag_hint_and_filter()
    {
        var builder = new RecordingBuilder();
        var factory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("HiddenByHint", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: true),
            new AbCipDiscoveredTag("Routine:Foo", null, AbCipDataType.DInt, ReadOnly: false, IsSystemTag: false),
            new AbCipDiscoveredTag("KeepMe", null, AbCipDataType.DInt, ReadOnly: false));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Variables.Select(v => v.Info.FullName).ShouldBe(["KeepMe"]);
    }

    /// <summary>Verifies that controller enumeration ReadOnly flag surfaces ViewOnly classification.</summary>
    [Fact]
    public async Task Controller_enumeration_ReadOnly_surfaces_ViewOnly_classification()
    {
        var builder = new RecordingBuilder();
        var factory = new FakeEnumeratorFactory(
            new AbCipDiscoveredTag("SafetyTag", null, AbCipDataType.DInt, ReadOnly: true));

        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

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

        builder.Variables.Single().Info.SecurityClass.ShouldBe(SecurityClassification.ViewOnly);
    }

    /// <summary>Verifies that controller enumeration receives correct device parameters.</summary>
    [Fact]
    public async Task Controller_enumeration_receives_correct_device_params()
    {
        var factory = new FakeEnumeratorFactory();
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5:44818/1,2,3", AbCipPlcFamily.ControlLogix)],
            Timeout = TimeSpan.FromSeconds(7),
            EnableControllerBrowse = true,
        }, "drv-1", enumeratorFactory: factory);
        await drv.InitializeAsync("{}", CancellationToken.None);

        await drv.DiscoverAsync(new RecordingBuilder(), CancellationToken.None);

        var capturedParams = factory.LastDeviceParams.ShouldNotBeNull();
        capturedParams.Gateway.ShouldBe("10.0.0.5");
        capturedParams.Port.ShouldBe(44818);
        capturedParams.CipPath.ShouldBe("1,2,3");
        capturedParams.LibplctagPlcAttribute.ShouldBe("controllogix");
        capturedParams.TagName.ShouldBe("@tags");
        capturedParams.Timeout.ShouldBe(TimeSpan.FromSeconds(7));
    }

    /// <summary>Verifies that default enumerator factory is used when not injected.</summary>
    [Fact]
    public void Default_enumerator_factory_is_used_when_not_injected()
    {
        // Sanity — absent enumerator factory does not crash discovery + uses EmptyAbCipTagEnumerator
        // (covered by the other tests which instantiate without injecting a factory).
        var drv = new AbCipDriver(new AbCipDriverOptions
        {
            Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")],
        }, "drv-1");
        drv.ShouldNotBeNull();
    }

    /// <summary>Verifies that system tag filter rejects infrastructure names.</summary>
    /// <param name="name">The tag name to test.</param>
    /// <param name="expected">The expected result of the filter.</param>
    [Theory]
    [InlineData("__DEFVAL_X", true)]
    [InlineData("__DEFAULT_Y", true)]
    [InlineData("Routine:Main", true)]
    [InlineData("Task:MainTask", true)]
    [InlineData("Local:1:I", true)]
    [InlineData("Map:Alias", true)]
    [InlineData("Axis:MoveX", true)]
    [InlineData("Cam:Profile1", true)]
    [InlineData("MotionGroup:MG0", true)]
    [InlineData("Motor1", false)]
    [InlineData("Program:Main.Step", false)]
    [InlineData("Recipe_2", false)]
    [InlineData("", true)]
    [InlineData("   ", true)]
    public void SystemTagFilter_rejects_infrastructure_names(string name, bool expected)
    {
        AbCipSystemTagFilter.IsSystemTag(name).ShouldBe(expected);
    }

    /// <summary>Verifies that template cache roundtrip put and get work correctly.</summary>
    [Fact]
    public void TemplateCache_roundtrip_put_get()
    {
        var cache = new AbCipTemplateCache();
        var shape = new AbCipUdtShape("MyUdt", 32,
        [
            new AbCipUdtMember("A", 0, AbCipDataType.DInt, ArrayLength: 1),
            new AbCipUdtMember("B", 4, AbCipDataType.Real, ArrayLength: 1),
        ]);
        cache.Put("ab://10.0.0.5/1,0", 42, shape);

        cache.TryGet("ab://10.0.0.5/1,0", 42).ShouldBe(shape);
        cache.TryGet("ab://10.0.0.5/1,0", 99).ShouldBeNull();
        cache.Count.ShouldBe(1);

        cache.Clear();
        cache.Count.ShouldBe(0);
    }

    /// <summary>Verifies that FlushOptionalCachesAsync clears the template cache.</summary>
    [Fact]
    public async Task FlushOptionalCachesAsync_clears_template_cache()
    {
        var drv = new AbCipDriver(new AbCipDriverOptions(), "drv-1");
        drv.TemplateCache.Put("dev", 1, new AbCipUdtShape("T", 4, []));
        drv.TemplateCache.Count.ShouldBe(1);

        await drv.FlushOptionalCachesAsync(CancellationToken.None);
        drv.TemplateCache.Count.ShouldBe(0);
    }

    // ---- helpers ----

    /// <summary>Test implementation of IAddressSpaceBuilder that records calls.</summary>
    private sealed class RecordingBuilder : IAddressSpaceBuilder
    {
        /// <summary>Gets the list of recorded folders.</summary>
        public List<(string BrowseName, string DisplayName)> Folders { get; } = new();
        /// <summary>Gets the list of recorded variables.</summary>
        public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = new();

        /// <summary>Records a folder node.</summary>
        /// <param name="browseName">The browse name of the folder.</param>
        /// <param name="displayName">The display name of the folder.</param>
        public IAddressSpaceBuilder Folder(string browseName, string displayName)
        { Folders.Add((browseName, displayName)); return this; }

        /// <summary>Records a variable node.</summary>
        /// <param name="browseName">The browse name of the variable.</param>
        /// <param name="displayName">The display name of the variable.</param>
        /// <param name="info">The attribute information for the variable.</param>
        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo info)
        { Variables.Add((browseName, info)); return new Handle(info.FullName); }

        /// <summary>Adds a property (no-op in test).</summary>
        /// <param name="_">Property name (unused in test).</param>
        /// <param name="__">Property data type (unused in test).</param>
        /// <param name="___">Property value (unused in test).</param>
        public void AddProperty(string _, DriverDataType __, object? ___) { }

        /// <summary>Test variable handle.</summary>
        private sealed class Handle(string fullRef) : IVariableHandle
        {
            /// <summary>Gets the full reference of the variable.</summary>
            public string FullReference => fullRef;
            /// <summary>Marks the variable as an alarm condition.</summary>
            /// <param name="info">The alarm condition information.</param>
            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NullSink();
        }
        /// <summary>Null sink for alarm conditions.</summary>
        private sealed class NullSink : IAlarmConditionSink
        {
            /// <summary>Handles alarm transition (no-op).</summary>
            /// <param name="args">The alarm event arguments.</param>
            public void OnTransition(AlarmEventArgs args) { }
        }
    }

    /// <summary>Fake enumerator factory for testing.</summary>
    private sealed class FakeEnumeratorFactory : IAbCipTagEnumeratorFactory
    {
        private readonly AbCipDiscoveredTag[] _tags;
        /// <summary>Gets the last captured device parameters.</summary>
        public AbCipTagCreateParams? LastDeviceParams { get; private set; }
        /// <summary>Initializes a new instance of the FakeEnumeratorFactory.</summary>
        /// <param name="tags">The tags to enumerate.</param>
        public FakeEnumeratorFactory(params AbCipDiscoveredTag[] tags) => _tags = tags;
        /// <summary>Creates a new fake enumerator.</summary>
        public IAbCipTagEnumerator Create() => new FakeEnumerator(this);

        /// <summary>Fake tag enumerator for testing.</summary>
        private sealed class FakeEnumerator(FakeEnumeratorFactory outer) : IAbCipTagEnumerator
        {
            /// <summary>Enumerates discovered tags asynchronously.</summary>
            /// <param name="deviceParams">The device parameters for enumeration.</param>
            /// <param name="cancellationToken">The cancellation token.</param>
            public async IAsyncEnumerable<AbCipDiscoveredTag> EnumerateAsync(
                AbCipTagCreateParams deviceParams,
                [EnumeratorCancellation] CancellationToken cancellationToken)
            {
                outer.LastDeviceParams = deviceParams;
                await Task.CompletedTask;
                foreach (var t in outer._tags) yield return t;
            }
            /// <summary>Disposes the enumerator.</summary>
            public void Dispose() { }
        }
    }

    /// <summary>
    ///     Fake <see cref="IAbCipTemplateReaderFactory"/> whose readers return a Template Object blob
    ///     keyed by template instance id — the production fetch path (FetchUdtShapeAsync) keyed by the
    ///     id the Symbol Object decoder surfaces, with NO use of the seed seam.
    /// </summary>
    private sealed class IdKeyedTemplateReaderFactory : IAbCipTemplateReaderFactory
    {
        /// <summary>Gets the template blobs keyed by template instance id.</summary>
        public Dictionary<uint, byte[]> Templates { get; } = new();
        /// <summary>Gets the last template id any reader was asked for.</summary>
        public uint? LastTemplateId { get; private set; }
        /// <summary>Gets every template id any reader was asked for, in request order.</summary>
        public List<uint> RequestedIds { get; } = new();

        /// <summary>Creates a reader over the shared template map.</summary>
        public IAbCipTemplateReader Create() => new Reader(this);

        private sealed class Reader(IdKeyedTemplateReaderFactory outer) : IAbCipTemplateReader
        {
            public Task<byte[]> ReadAsync(
                AbCipTagCreateParams deviceParams, uint templateInstanceId, CancellationToken cancellationToken)
            {
                outer.LastTemplateId = templateInstanceId;
                outer.RequestedIds.Add(templateInstanceId);
                return Task.FromResult(
                    outer.Templates.TryGetValue(templateInstanceId, out var blob) ? blob : []);
            }

            public void Dispose() { }
        }
    }

    /// <summary>
    ///     Build a minimal CIP Template Object blob <see cref="CipTemplateObjectDecoder"/> can decode
    ///     into an <see cref="AbCipUdtShape"/>. Mirrors the helper in
    ///     <c>AbCipFetchUdtShapeTests</c>: 12-byte header + 8-byte member blocks + semicolon/NUL
    ///     terminated UDT-then-member name strings.
    /// </summary>
    private static byte[] BuildSimpleTemplate(
        string name, uint instanceSize, params (string n, ushort info, ushort arr, uint off)[] members)
    {
        const int headerSize = 12;
        const int blockSize = 8;
        var strings = new MemoryStream();
        void Add(string s) { var b = Encoding.ASCII.GetBytes(s + ";\0"); strings.Write(b, 0, b.Length); }
        Add(name);
        foreach (var m in members) Add(m.n);
        var stringsArr = strings.ToArray();

        var buf = new byte[headerSize + blockSize * members.Length + stringsArr.Length];
        BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(0), (ushort)members.Length);
        BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(2), 0x1234);
        BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(4), instanceSize);
        for (var i = 0; i < members.Length; i++)
        {
            var o = headerSize + i * blockSize;
            BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o), members[i].info);
            BinaryPrimitives.WriteUInt16LittleEndian(buf.AsSpan(o + 2), members[i].arr);
            BinaryPrimitives.WriteUInt32LittleEndian(buf.AsSpan(o + 4), members[i].off);
        }
        Buffer.BlockCopy(stringsArr, 0, buf, headerSize + blockSize * members.Length, stringsArr.Length);
        return buf;
    }
}
