using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browse;

namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Browse;

/// <summary>
///     Tests <see cref="GalaxyDiscoverer"/>'s translation of <see cref="GalaxyObject"/> rows
///     into <see cref="IAddressSpaceBuilder"/> calls. The fake builder records every Folder /
///     Variable / MarkAsAlarmCondition call so assertions can target shape without booting
///     a real OPC UA address space.
/// </summary>
public sealed class GalaxyDiscovererTests
{
    private sealed class FakeHierarchySource(IReadOnlyList<GalaxyObject> objects) : IGalaxyHierarchySource
    {
        /// <summary>Gets the hierarchy asynchronously from the fake source.</summary>
        /// <param name="cancellationToken">The cancellation token.</param>
        /// <returns>A task that returns the pre-built Galaxy object list.</returns>
        public Task<IReadOnlyList<GalaxyObject>> GetHierarchyAsync(CancellationToken cancellationToken)
            => Task.FromResult(objects);
    }

    /// <summary><c>ParentBrowseName</c> is the browse-name of the folder this folder was created under
    /// (<c>null</c> = created at the driver root).</summary>
    private sealed record FolderCall(string BrowseName, string DisplayName, string? ParentBrowseName);
    private sealed record VariableCall(string FolderBrowseName, string AttributeName, DriverAttributeInfo Info);

    private sealed class FakeBuilder : IAddressSpaceBuilder
    {
        /// <summary>Gets the list of folder creation calls recorded by this builder.</summary>
        public List<FolderCall> Folders { get; } = [];
        /// <summary>Gets the list of variable creation calls recorded by this builder.</summary>
        public List<VariableCall> Variables { get; } = [];
        /// <summary>Gets the dictionary of alarm declarations recorded by this builder.</summary>
        public Dictionary<string, AlarmConditionInfo> AlarmDeclarations { get; } = [];

        private readonly string? _currentFolder;

        /// <summary>Initializes a new instance of the FakeBuilder class at the root level.</summary>
        public FakeBuilder() : this(null) { }
        private FakeBuilder(string? folder) { _currentFolder = folder; }

        /// <summary>Adds a folder call to the recorded list.</summary>
        /// <param name="browseName">The browse name for the folder.</param>
        /// <param name="displayName">The display name for the folder.</param>
        /// <returns>An IAddressSpaceBuilder scoped to the new folder.</returns>
        public IAddressSpaceBuilder Folder(string browseName, string displayName)
        {
            // Root-level folder — no parent.
            Folders.Add(new FolderCall(browseName, displayName, ParentBrowseName: null));
            // Return a child scoped to this folder; nested folders inherit the parent reference.
            return new ChildBuilder(this, browseName);
        }

        /// <summary>Adds a variable call to the recorded list.</summary>
        /// <param name="browseName">The browse name for the variable.</param>
        /// <param name="displayName">The display name for the variable.</param>
        /// <param name="attributeInfo">The attribute metadata for the variable.</param>
        /// <returns>An IVariableHandle for further configuration.</returns>
        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
        {
            var folder = _currentFolder ?? "<root>";
            Variables.Add(new VariableCall(folder, browseName, attributeInfo));
            return new FakeVariableHandle(this, attributeInfo.FullName);
        }

        /// <summary>Adds a property call to the builder (not recorded in this fake).</summary>
        /// <param name="browseName">The browse name for the property.</param>
        /// <param name="dataType">The driver data type of the property.</param>
        /// <param name="value">The property value.</param>
        public void AddProperty(string browseName, DriverDataType dataType, object? value) { }

        /// <summary>Child folder routes Variable calls back to the parent's lists with its own scope.</summary>
        private sealed class ChildBuilder(FakeBuilder parent, string folderBrowseName) : IAddressSpaceBuilder
        {
            /// <summary>Adds a child folder call to the parent builder's recorded list.</summary>
            /// <param name="browseName">The browse name for the folder.</param>
            /// <param name="displayName">The display name for the folder.</param>
            /// <returns>An IAddressSpaceBuilder scoped to the new child folder.</returns>
            public IAddressSpaceBuilder Folder(string browseName, string displayName)
            {
                // Nested folder — record the folder it was created under.
                parent.Folders.Add(new FolderCall(browseName, displayName, ParentBrowseName: folderBrowseName));
                return new ChildBuilder(parent, browseName);
            }

            /// <summary>Adds a variable call to the parent builder's recorded list, scoped to this folder.</summary>
            /// <param name="browseName">The browse name for the variable.</param>
            /// <param name="displayName">The display name for the variable.</param>
            /// <param name="attributeInfo">The attribute metadata for the variable.</param>
            /// <returns>An IVariableHandle for further configuration.</returns>
            public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
            {
                parent.Variables.Add(new VariableCall(folderBrowseName, browseName, attributeInfo));
                return new FakeVariableHandle(parent, attributeInfo.FullName);
            }

            /// <summary>Adds a property call to the builder (not recorded in this fake).</summary>
            /// <param name="browseName">The browse name for the property.</param>
            /// <param name="dataType">The driver data type of the property.</param>
            /// <param name="value">The property value.</param>
            public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
        }

        private sealed class FakeVariableHandle(FakeBuilder owner, string fullRef) : IVariableHandle
        {
            /// <summary>Gets the full reference for this variable.</summary>
            public string FullReference { get; } = fullRef;

            /// <summary>Marks this variable as an alarm condition and records it.</summary>
            /// <param name="info">The alarm condition metadata.</param>
            /// <returns>An IAlarmConditionSink for further alarm configuration.</returns>
            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
            {
                owner.AlarmDeclarations[FullReference] = info;
                return new NoopSink();
            }
        }

        private sealed class NoopSink : IAlarmConditionSink
        {
            /// <summary>Records an alarm transition event (no-op in this fake).</summary>
            /// <param name="args">The alarm event arguments.</param>
            public void OnTransition(AlarmEventArgs args) { }
        }
    }

    private static GalaxyAttribute Attr(
        string name, int mxDataType = 0, bool isArray = false, int arrayDim = 0,
        int securityClass = 0, bool isHistorized = false, bool isAlarm = false,
        string? fullTagReference = null)
    {
        var a = new GalaxyAttribute
        {
            AttributeName = name,
            MxDataType = mxDataType,
            IsArray = isArray,
            ArrayDimension = arrayDim,
            ArrayDimensionPresent = arrayDim > 0,
            SecurityClassification = securityClass,
            IsHistorized = isHistorized,
            IsAlarm = isAlarm,
        };
        if (fullTagReference is not null) a.FullTagReference = fullTagReference;
        return a;
    }

    private static GalaxyObject Obj(string tagName, string? containedName = null, params GalaxyAttribute[] attributes)
    {
        var o = new GalaxyObject
        {
            TagName = tagName,
            ContainedName = containedName ?? tagName,
        };
        o.Attributes.AddRange(attributes);
        return o;
    }

    /// <summary>Builds a gobject with explicit id + parent id so nesting-by-parent can be asserted.</summary>
    private static GalaxyObject Node(
        int gobjectId, int parentGobjectId, string tagName,
        string? containedName = null, params GalaxyAttribute[] attributes)
    {
        var o = new GalaxyObject
        {
            GobjectId = gobjectId,
            ParentGobjectId = parentGobjectId,
            TagName = tagName,
            ContainedName = containedName ?? tagName,
        };
        o.Attributes.AddRange(attributes);
        return o;
    }

    /// <summary>Verifies that discovery creates one folder per object and one variable per attribute.</summary>
    [Fact]
    public async Task DiscoverAsync_BuildsOneFolderPerObject_AndOneVariablePerAttribute()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2 /*Float32*/), Attr("SP", mxDataType: 2)),
            Obj("Tank1_Pump", "Pump", Attr("Running", mxDataType: 0 /*Boolean*/)),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.Folders.Count.ShouldBe(2);
        builder.Folders[0].BrowseName.ShouldBe("Level");
        builder.Folders[1].BrowseName.ShouldBe("Pump");
        builder.Variables.Count.ShouldBe(3);
        builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "PV");
        builder.Variables.ShouldContain(v => v.FolderBrowseName == "Level" && v.AttributeName == "SP");
        builder.Variables.ShouldContain(v => v.FolderBrowseName == "Pump"  && v.AttributeName == "Running");
    }

    /// <summary>Verifies that full reference defaults to tag.attribute format when not explicitly supplied.</summary>
    [Fact]
    public async Task DiscoverAsync_FullReference_DefaultsToTagDotAttribute()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2)),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.Variables[0].Info.FullName.ShouldBe("Tank1_Level.PV");
    }

    /// <summary>Verifies that full reference uses gateway-supplied value when provided.</summary>
    [Fact]
    public async Task DiscoverAsync_FullReference_PrefersGwSuppliedFullTagReference()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2, fullTagReference: "explicit.full.ref")),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.Variables[0].Info.FullName.ShouldBe("explicit.full.ref");
    }

    /// <summary>Verifies that browse name falls back to tag name when contained name is empty.</summary>
    [Fact]
    public async Task DiscoverAsync_BrowseName_FallsBackToTagName_WhenContainedEmpty()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", containedName: "", attributes: Attr("PV")),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.Folders[0].BrowseName.ShouldBe("Tank1_Level");
    }

    /// <summary>Verifies that attribute metadata fields are all propagated to the discovered variable.</summary>
    [Fact]
    public async Task DiscoverAsync_AttributeMetadata_PropagatesEveryField()
    {
        var src = new FakeHierarchySource([
            Obj("T", "T", Attr("PV",
                mxDataType: 3 /*Float64*/,
                isArray: true, arrayDim: 16,
                securityClass: 2 /*SecuredWrite*/,
                isHistorized: true, isAlarm: false)),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        var info = builder.Variables[0].Info;
        info.DriverDataType.ShouldBe(DriverDataType.Float64);
        info.IsArray.ShouldBeTrue();
        info.ArrayDim.ShouldBe(16u);
        info.SecurityClass.ShouldBe(SecurityClassification.SecuredWrite);
        info.IsHistorized.ShouldBeTrue();
        info.IsAlarm.ShouldBeFalse();
    }

    /// <summary>Verifies that alarm attributes populate all five sub-attribute references.</summary>
    [Fact]
    public async Task DiscoverAsync_AlarmAttribute_PopulatesAllFiveSubAttributeRefs()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.AlarmDeclarations.ShouldContainKey("Tank1_Level.HiHi");
        var info = builder.AlarmDeclarations["Tank1_Level.HiHi"];
        info.SourceName.ShouldBe("Tank1_Level.HiHi");
        info.InAlarmRef.ShouldBe("Tank1_Level.HiHi.InAlarm");
        info.PriorityRef.ShouldBe("Tank1_Level.HiHi.Priority");
        info.DescAttrNameRef.ShouldBe("Tank1_Level.HiHi.DescAttrName");
        info.AckedRef.ShouldBe("Tank1_Level.HiHi.Acked");
        info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
    }

    /// <summary>
    ///     v3 RawPath keying: when a resolver maps a discovered Galaxy attributeRef to its authored
    ///     <see cref="RawTagEntry"/>, the emitted node reference (<see cref="DriverAttributeInfo.FullName"/>)
    ///     is the entry's <b>RawPath</b> — not the attributeRef — and the entry's WriteIdempotent flag flows
    ///     onto the descriptor. Unmapped attributes degrade to the attributeRef with WriteIdempotent=false.
    /// </summary>
    [Fact]
    public async Task DiscoverAsync_WithRawPathResolver_EmitsRawPathAsFullName_AndThreadsWriteIdempotent()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", "Level", Attr("PV", mxDataType: 2), Attr("SP", mxDataType: 2)),
        ]);
        // Authored: Tank1_Level.PV → RawPath "area/line/tank1/level" (write-idempotent). SP is NOT authored.
        var authored = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase)
        {
            ["Tank1_Level.PV"] = new RawTagEntry(
                "area/line/tank1/level", "{\"attributeRef\":\"Tank1_Level.PV\"}", WriteIdempotent: true),
        };
        var discoverer = new GalaxyDiscoverer(src, attrRef => authored.GetValueOrDefault(attrRef));
        var builder = new FakeBuilder();

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

        var pv = builder.Variables.Single(v => v.AttributeName == "PV").Info;
        pv.FullName.ShouldBe("area/line/tank1/level"); // RawPath, not the attributeRef
        pv.WriteIdempotent.ShouldBeTrue();

        var sp = builder.Variables.Single(v => v.AttributeName == "SP").Info;
        sp.FullName.ShouldBe("Tank1_Level.SP"); // unmapped → attributeRef fallback
        sp.WriteIdempotent.ShouldBeFalse();
    }

    /// <summary>
    ///     v3 alarm keying: the alarm condition identity (<see cref="AlarmConditionInfo.SourceName"/>, and
    ///     the ConditionId the driver reports) is the tag's <b>RawPath</b>, while the five live-state /
    ///     ack sub-refs are dialled off the Galaxy <b>attributeRef</b> — so the server subscribes the real
    ///     MXAccess attributes while correlating the condition to its RawPath-keyed node.
    /// </summary>
    [Fact]
    public async Task DiscoverAsync_WithRawPathResolver_AlarmConditionUsesRawPathIdentity_AndAttributeRefSubRefs()
    {
        var src = new FakeHierarchySource([
            Obj("Tank1_Level", "Level", Attr("HiHi", mxDataType: 0, isAlarm: true)),
        ]);
        var authored = new Dictionary<string, RawTagEntry>(StringComparer.OrdinalIgnoreCase)
        {
            ["Tank1_Level.HiHi"] = new RawTagEntry(
                "area/line/tank1/hihi", "{\"attributeRef\":\"Tank1_Level.HiHi\"}", WriteIdempotent: false),
        };
        var discoverer = new GalaxyDiscoverer(src, attrRef => authored.GetValueOrDefault(attrRef));
        var builder = new FakeBuilder();

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

        // Alarm declaration is keyed (by the fake builder) on the variable's FullReference = RawPath.
        builder.AlarmDeclarations.ShouldContainKey("area/line/tank1/hihi");
        var info = builder.AlarmDeclarations["area/line/tank1/hihi"];
        info.SourceName.ShouldBe("area/line/tank1/hihi"); // RawPath identity
        // Sub-refs dial off the Galaxy attributeRef (tag_name.AttributeName), NOT the RawPath.
        info.InAlarmRef.ShouldBe("Tank1_Level.HiHi.InAlarm");
        info.PriorityRef.ShouldBe("Tank1_Level.HiHi.Priority");
        info.DescAttrNameRef.ShouldBe("Tank1_Level.HiHi.DescAttrName");
        info.AckedRef.ShouldBe("Tank1_Level.HiHi.Acked");
        info.AckMsgWriteRef.ShouldBe("Tank1_Level.HiHi.AckMsg");
    }

    /// <summary>Verifies that non-alarm attributes are not marked as alarm conditions.</summary>
    [Fact]
    public async Task DiscoverAsync_NonAlarmAttribute_DoesNotMarkCondition()
    {
        var src = new FakeHierarchySource([
            Obj("T", "T", Attr("PV", isAlarm: false), Attr("HiHi", isAlarm: true)),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.AlarmDeclarations.Count.ShouldBe(1);
        builder.AlarmDeclarations.ShouldContainKey("T.HiHi");
        builder.AlarmDeclarations.ShouldNotContainKey("T.PV");
    }

    /// <summary>Verifies that objects with empty identity are skipped during discovery.</summary>
    [Fact]
    public async Task DiscoverAsync_SkipsObjectsWithEmptyIdentity()
    {
        var src = new FakeHierarchySource([
            new GalaxyObject { TagName = "", ContainedName = "" }, // skip
            Obj("Real", "Real", Attr("PV")),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.Folders.Count.ShouldBe(1);
        builder.Folders[0].BrowseName.ShouldBe("Real");
    }

    /// <summary>Verifies that attributes with empty names are skipped during discovery.</summary>
    [Fact]
    public async Task DiscoverAsync_SkipsAttributesWithEmptyName()
    {
        var src = new FakeHierarchySource([
            Obj("T", "T", new GalaxyAttribute { AttributeName = "", MxDataType = 0 }, Attr("PV")),
        ]);
        var discoverer = new GalaxyDiscoverer(src);
        var builder = new FakeBuilder();

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

        builder.Variables.Count.ShouldBe(1);
        builder.Variables[0].AttributeName.ShouldBe("PV");
    }

    /// <summary>Verifies that driver discovery routes through the injected hierarchy source.</summary>
    [Fact]
    public async Task DriverDiscoverAsync_RoutesThroughInjectedSource()
    {
        var src = new FakeHierarchySource([Obj("T", "T", Attr("PV"))]);
        var driver = new GalaxyDriverHelper().CreateWithFakeSource(src);
        var builder = new FakeBuilder();

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

        builder.Folders.Count.ShouldBe(1);
        builder.Variables.Count.ShouldBe(1);
    }

    /// <summary>Child gobject's folder is created UNDER its parent's folder, not at the root.</summary>
    [Fact]
    public async Task Nests_child_folder_under_its_parent()
    {
        var src = new FakeHierarchySource([
            Node(1, 0, "Parent", "parent", Attr("PV")),
            Node(2, 1, "Child", "child", Attr("PV")),
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        var parent = builder.Folders.Single(f => f.BrowseName == "parent");
        parent.ParentBrowseName.ShouldBeNull();
        var child = builder.Folders.Single(f => f.BrowseName == "child");
        child.ParentBrowseName.ShouldBe("parent");
    }

    /// <summary>Nesting is order-independent: child returned before its parent still nests correctly.</summary>
    [Fact]
    public async Task Is_order_independent_child_before_parent()
    {
        var src = new FakeHierarchySource([
            Node(2, 1, "Child", "child", Attr("PV")), // child FIRST
            Node(1, 0, "Parent", "parent", Attr("PV")),
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        var child = builder.Folders.Single(f => f.BrowseName == "child");
        child.ParentBrowseName.ShouldBe("parent");
    }

    /// <summary>Both gobjects with parent=0 land flat at the driver root.</summary>
    [Fact]
    public async Task Degrades_to_flat_when_parent_is_zero()
    {
        var src = new FakeHierarchySource([
            Node(1, 0, "A", "a", Attr("PV")),
            Node(2, 0, "B", "b", Attr("PV")),
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        builder.Folders.Single(f => f.BrowseName == "a").ParentBrowseName.ShouldBeNull();
        builder.Folders.Single(f => f.BrowseName == "b").ParentBrowseName.ShouldBeNull();
    }

    /// <summary>A child whose parent_gobject_id is not in the returned set degrades to the root.</summary>
    [Fact]
    public async Task Degrades_to_flat_when_parent_not_in_set()
    {
        var src = new FakeHierarchySource([
            Node(2, 99, "Child", "child", Attr("PV")), // parent 99 absent
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        builder.Folders.Single(f => f.BrowseName == "child").ParentBrowseName.ShouldBeNull();
    }

    /// <summary>A self-referential parent (id == parent id) attaches to the root, never to itself.</summary>
    [Fact]
    public async Task Self_parent_attaches_to_root()
    {
        var src = new FakeHierarchySource([
            Node(5, 5, "Selfie", "selfie", Attr("PV")),
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        builder.Folders.Single(f => f.BrowseName == "selfie").ParentBrowseName.ShouldBeNull();
    }

    /// <summary>A mutual parent cycle (A→B, B→A) degrades EVERY member to the root — neither
    /// folder nests under the other, and discovery stays finite (no infinite recursion / crash).</summary>
    [Fact]
    public async Task Mutual_cycle_degrades_both_to_root()
    {
        var src = new FakeHierarchySource([
            Node(1, 2, "a", "a", Attr("PV")), // A's parent is B
            Node(2, 1, "b", "b", Attr("PV")), // B's parent is A
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        builder.Folders.Single(f => f.BrowseName == "a").ParentBrowseName.ShouldBeNull();
        builder.Folders.Single(f => f.BrowseName == "b").ParentBrowseName.ShouldBeNull();
    }

    /// <summary>A child object's attributes are added into the child's own folder, not the parent's.</summary>
    [Fact]
    public async Task Variables_land_in_their_owner_folder()
    {
        var src = new FakeHierarchySource([
            Node(1, 0, "Parent", "parent", Attr("ParentPV")),
            Node(2, 1, "Child", "child", Attr("ChildPV")),
        ]);
        var builder = new FakeBuilder();

        await new GalaxyDiscoverer(src).DiscoverAsync(builder, CancellationToken.None);

        builder.Variables.ShouldContain(v => v.FolderBrowseName == "parent" && v.AttributeName == "ParentPV");
        builder.Variables.ShouldContain(v => v.FolderBrowseName == "child" && v.AttributeName == "ChildPV");
        builder.Variables.ShouldNotContain(v => v.FolderBrowseName == "parent" && v.AttributeName == "ChildPV");
    }

    /// <summary>Helper that exercises the internal ctor (test seam) without exposing it publicly.</summary>
    private sealed class GalaxyDriverHelper
    {
        /// <summary>Creates a GalaxyDriver with a fake hierarchy source for testing.</summary>
        /// <param name="source">The fake hierarchy source to inject.</param>
        /// <returns>A GalaxyDriver configured with the fake source.</returns>
        public GalaxyDriver CreateWithFakeSource(IGalaxyHierarchySource source)
            => new GalaxyDriver(
                "galaxy-test",
                new Config.GalaxyDriverOptions(
                    new Config.GalaxyGatewayOptions("https://x", "k"),
                    new Config.GalaxyMxAccessOptions("OtOpcUa-T"),
                    new Config.GalaxyRepositoryOptions(),
                    new Config.GalaxyReconnectOptions()),
                source,
                logger: null);
    }
}
