using System.Collections.Concurrent;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     Custom OPC UA <see cref="CustomNodeManager2"/> that owns the writable address space for
///     the OtOpcUa server. Variable nodes are created lazily on first <see cref="WriteValue"/>
///     under the manager's namespace; subsequent writes update the existing node's Value +
///     StatusCode + SourceTimestamp and notify subscribed clients via the standard
///     <c>ClearChangeMasks</c> path.
///
///     This is the F10b production wiring behind the v2 <see cref="IOpcUaAddressSpaceSink"/>
///     seam — once a <see cref="SdkAddressSpaceSink"/> is bound, OpcUaPublishActor's writes
///     materialise as real OPC UA Variable updates that clients can browse + subscribe to.
///
///     Node-id encoding uses the manager's default namespace + the caller-supplied string id
///     as the identifier portion (e.g. <c>"ns=2;s=eq-1/temp"</c>). Equipment-folder hierarchy
///     and OPC UA type metadata still come from the Phase7Applier / EquipmentNodeWalker
///     integration (F14b, tracked under #85) — this manager treats every id as a flat
///     <see cref="BaseDataVariableState"/> under the namespace root.
/// </summary>
public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
    public const string DefaultNamespaceUri = "https://zb.com/otopcua/ns";

    private readonly ConcurrentDictionary<string, BaseDataVariableState> _variables = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, FolderState> _folders = new(StringComparer.Ordinal);
    private readonly ConcurrentDictionary<string, AlarmConditionState> _alarmConditions = new(StringComparer.Ordinal);
    /// <summary>Folders we have already promoted to event-notifiers + registered as root notifiers,
    /// so repeated <see cref="MaterialiseAlarmCondition"/> calls don't double-add (idempotent guard).
    /// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
    /// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
    private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
    private FolderState? _root;

    /// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
    /// <param name="server">The OPC UA server instance.</param>
    /// <param name="configuration">The application configuration.</param>
    public OtOpcUaNodeManager(IServerInternal server, ApplicationConfiguration configuration)
        : base(server, configuration, DefaultNamespaceUri)
    {
        // SystemContext is initialised by the base ctor.
    }

    /// <summary>Gets the count of variable nodes currently managed.</summary>
    public int VariableCount => _variables.Count;
    /// <summary>Gets the count of folder nodes currently managed.</summary>
    public int FolderCount => _folders.Count;
    /// <summary>Gets the count of real Part 9 <see cref="AlarmConditionState"/> nodes currently managed.</summary>
    public int AlarmConditionCount => _alarmConditions.Count;

    /// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
    /// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
    /// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
    /// <returns>The cached <see cref="AlarmConditionState"/>, or null when none is registered.</returns>
    public AlarmConditionState? TryGetAlarmCondition(string alarmNodeId) =>
        _alarmConditions.TryGetValue(alarmNodeId, out var condition) ? condition : null;

    /// <summary>
    ///     Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
    ///     variable node on first call; subsequent calls update Value + StatusCode +
    ///     SourceTimestamp and call <c>ClearChangeMasks</c> so subscribed clients see the change.
    /// </summary>
    /// <param name="nodeId">The node identifier of the variable.</param>
    /// <param name="value">The new value to write.</param>
    /// <param name="quality">The OPC UA quality status code.</param>
    /// <param name="sourceTimestampUtc">The timestamp of the value in UTC.</param>
    public void WriteValue(string nodeId, object? value, OpcUaQuality quality, DateTime sourceTimestampUtc)
    {
        ArgumentException.ThrowIfNullOrEmpty(nodeId);

        lock (Lock)
        {
            // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode),
            // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too.
            if (!_variables.TryGetValue(nodeId, out var variable))
            {
                variable = CreateVariable(nodeId);
                _variables[nodeId] = variable;
            }

            variable.Value = value;
            variable.StatusCode = StatusFromQuality(quality);
            variable.Timestamp = sourceTimestampUtc;
            variable.ClearChangeMasks(SystemContext, includeChildren: false);
        }
    }

    /// <summary>
    ///     Apply a full Part 9 alarm-condition write. When a real <see cref="AlarmConditionState"/> has
    ///     been materialised for <paramref name="alarmNodeId"/> (via <see cref="MaterialiseAlarmCondition"/>),
    ///     this projects the whole <paramref name="state"/> snapshot
    ///     (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition
    ///     node and recomputes Retain (T15 — richer state; <b>still no event firing</b>, that lands in T16).
    ///     Otherwise it falls back to the legacy two-element <c>[Active, Acknowledged]</c>
    ///     <see cref="BaseDataVariableState"/> placeholder so callers whose alarm node hasn't been
    ///     materialised (and the existing unit tests) keep working.
    /// </summary>
    /// <param name="alarmNodeId">The node identifier of the alarm (== ScriptedAlarmId for materialised conditions).</param>
    /// <param name="state">The full condition state to project onto the node.</param>
    /// <param name="sourceTimestampUtc">The timestamp of the alarm state change in UTC.</param>
    public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime sourceTimestampUtc)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        ArgumentNullException.ThrowIfNull(state);

        // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear
        // _alarmConditions / detach the condition node between the lookup and the Set* calls.
        lock (Lock)
        {
            if (_alarmConditions.TryGetValue(alarmNodeId, out var condition))
            {
                // EnabledState / AckedState / ActiveState are mandatory children — always present after
                // Create. Confirm + Shelving are optional Part 9 children: T14's real-server finding is
                // that Create auto-builds them for our subtypes, but a base AlarmConditionState (or a
                // future SDK that builds a leaner child set) may leave them null. Null-guard each optional
                // child so projecting Confirmed/Shelving onto a node that lacks the sub-state machine is a
                // no-op rather than an NRE.
                condition.SetEnableState(SystemContext, state.Enabled);
                condition.SetActiveState(SystemContext, state.Active);
                condition.SetAcknowledgedState(SystemContext, state.Acknowledged);
                if (condition.ConfirmedState is not null)
                {
                    condition.SetConfirmedState(SystemContext, state.Confirmed);
                }
                if (condition.ShelvingState is not null)
                {
                    // SetShelvingState(shelved, oneShot, shelvingTime): map our 3-way kind onto the SDK's
                    // (shelved, oneShot) flag pair. Timed shelving's expiry is owned by the engine, not the
                    // SDK timer, so we pass shelvingTime=0 (no SDK-managed auto-unshelve).
                    condition.SetShelvingState(
                        SystemContext,
                        shelved: state.Shelving != AlarmShelvingKind.Unshelved,
                        oneShot: state.Shelving == AlarmShelvingKind.OneShot,
                        shelvingTime: 0);
                }
                condition.SetSeverity(SystemContext, MapSeverity(state.Severity));
                condition.Message.Value = new LocalizedText(state.Message);

                // Part 9: retain the condition while it is active OR unacknowledged so a client's
                // ConditionRefresh replays it. The event firing below also depends on this Retain being
                // correct (a non-retained inactive+acked condition still fires its transition event, but
                // won't be replayed on a later ConditionRefresh).
                condition.Retain.Value = state.Active || !state.Acknowledged;
                condition.Time.Value = sourceTimestampUtc;
                condition.ReceiveTime.Value = sourceTimestampUtc;

                // T16 — fire a real Part 9 condition event for THIS engine-driven transition. The
                // ScriptedAlarmHostActor only calls WriteAlarmCondition on a REAL state transition
                // (Emission != None/Suppressed), so every call corresponds to exactly one transition →
                // fire exactly one event. ReportConditionEvent stamps a fresh EventId, ClearChangeMasks,
                // and ReportEvent — all still under this lock.
                ReportConditionEvent(condition, sourceTimestampUtc);
                return;
            }

            // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so
            // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates
            // the SDK address space, so it MUST run under Lock (see WriteValue).
            if (!_variables.TryGetValue(alarmNodeId, out var variable))
            {
                variable = CreateVariable(alarmNodeId);
                _variables[alarmNodeId] = variable;
            }

            variable.Value = new[] { state.Active, state.Acknowledged };
            variable.StatusCode = StatusCodes.Good;
            variable.Timestamp = sourceTimestampUtc;
            variable.ClearChangeMasks(SystemContext, includeChildren: false);
        }
    }

    /// <summary>
    ///     Fire a real OPC UA Part 9 condition event for one engine-driven state transition on a
    ///     materialised <see cref="AlarmConditionState"/>. The caller MUST already hold <c>Lock</c> and
    ///     have applied the new state via the <c>Set*</c> projection — this stamps a fresh per-event
    ///     <c>EventId</c>, <c>ClearChangeMasks</c>, then <c>ReportEvent</c> with an
    ///     <see cref="InstanceStateSnapshot"/> (a frozen copy of the condition's children at fire time,
    ///     so a subscribing client sees the values at this instant even if the live node mutates after).
    ///     <para>
    ///         A fresh <c>EventId</c> per event is a Part 9 requirement: inbound Acknowledge / Confirm /
    ///         AddComment calls are correlated back to a specific event by this id (the SDK matches it via
    ///         <c>GetEventByEventId</c> / <c>GetBranch</c>), so T17's ack routing relies on it being unique
    ///         per emission. We use the main branch only (<c>BranchId == NodeId.Null</c>, set at
    ///         materialise) — no branch creation here.
    ///     </para>
    ///     <para>
    ///         <b>Double-emit note (for T17).</b> This helper fires ONLY for ENGINE-DRIVEN (outbound)
    ///         transitions routed through <see cref="WriteAlarmCondition"/>. T17's inbound
    ///         Acknowledge/Confirm will go through the SDK's own <c>OnAcknowledgeCalled</c>, which already
    ///         auto-fires a condition event (<c>ReportStateChange</c>) on a successful ack. These don't
    ///         overlap today (T17 isn't built), but once it lands an engine-driven re-projection of an ack
    ///         the SDK has ALREADY auto-emitted could double-emit the same logical transition — to be
    ///         reconciled in T17 (either suppress the SDK auto-fire, or skip the engine re-projection for
    ///         transitions that originated from an inbound method call).
    ///     </para>
    /// </summary>
    /// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
    /// <param name="ts">The source/receive timestamp (UTC) for this event.</param>
    private void ReportConditionEvent(AlarmConditionState alarm, DateTime ts)
    {
        // Fresh GUID-bytes EventId per event — mandatory for Part 9 ack correlation (T17 relies on it).
        alarm.EventId.Value = Guid.NewGuid().ToByteArray();
        // Time/ReceiveTime/Message/Severity were set by the WriteAlarmCondition projection above; restamp
        // Time/ReceiveTime here so this event carries this transition's instant even if the caller's
        // projection ordering changes later.
        alarm.Time.Value = ts;
        alarm.ReceiveTime.Value = ts;

        // Snapshot the children, then notify subscribers. ClearChangeMasks must precede the snapshot so
        // the InstanceStateSnapshot captures the just-projected values.
        alarm.ClearChangeMasks(SystemContext, includeChildren: true);

        try
        {
            // InstanceStateSnapshot is the IFilterTarget — a frozen copy of the condition's fields at fire
            // time. ReportEvent walks inverse notifier references up to the root-notifier folder (promoted
            // in MaterialiseAlarmCondition), whose OnReportEvent hands off to Server.ReportEvent → the
            // event reaches subscribed monitored items.
            var snapshot = new InstanceStateSnapshot();
            snapshot.Initialize(SystemContext, alarm);
            alarm.ReportEvent(SystemContext, snapshot);
        }
        catch (Exception ex)
        {
            // A failed event report must NOT break the state projection or the calling actor: the node's
            // state has already been applied + ClearChangeMasks'd, so attribute subscribers still see the
            // change; only the event delivery is lost. This CustomNodeManager2 carries no ILogger, so log
            // through the SDK's static trace (Utils.LogError) instead of swallowing silently — a recurring
            // failure here is then visible in the server log rather than invisible. T19's live Client.CLI
            // run is the integration proof that the happy path delivers.
            // Utils.LogError routes to the SDK's trace sink. It's [Obsolete] in 1.5.378 in favour of an
            // ITelemetryContext/ILogger this CustomNodeManager2 doesn't have wired — suppress the
            // deprecation here (wiring the telemetry logger through is a separate follow-up); the point is
            // that a recurring failure is visible in the server trace rather than silently swallowed.
#pragma warning disable CS0618 // Type or member is obsolete
            Utils.LogError(ex, "OtOpcUaNodeManager: failed to report Part 9 condition event for {0}", alarm.NodeId);
#pragma warning restore CS0618
        }
    }

    /// <summary>
    ///     Materialise a real OPC UA Part 9 <see cref="AlarmConditionState"/> node under its equipment
    ///     folder so clients can browse it as a proper condition (and subscribe to its events). The node
    ///     id is the alarm node id (the ScriptedAlarmId) so subsequent
    ///     <see cref="WriteAlarmCondition"/> calls — which target that same id — update this node.
    ///     <para>
    ///         This is the T14 production replacement for the <c>bool[2]</c> placeholder: it creates
    ///         node + basic Active/Ack state + the notifier wiring needed for T16 events, but fires
    ///         <b>no</b> events itself.
    ///     </para>
    ///     Idempotent: a second call with the same <paramref name="alarmNodeId"/> tears down the prior
    ///     node and re-creates it cleanly (so a redeploy with a changed type/severity is reflected).
    /// </summary>
    /// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId); becomes the condition's NodeId.</param>
    /// <param name="equipmentNodeId">The equipment folder node id the condition parents under (null/unknown ⇒ root).</param>
    /// <param name="displayName">Human-readable condition name (BrowseName / DisplayName / Message / ConditionName).</param>
    /// <param name="alarmType">Domain alarm type — maps to the SDK condition subtype (see remarks).</param>
    /// <param name="severity">Domain severity (treated as an OPC UA 1..1000 severity); mapped to <see cref="EventSeverity"/>.</param>
    /// <remarks>
    ///     <para><b>AlarmType → SDK subtype mapping.</b> Script-driven alarms have no OPC limit /
    ///     setpoint values, so any limit-style subtype would have unset limit children. We therefore
    ///     map: <c>OffNormalAlarm</c> → <see cref="OffNormalAlarmState"/>, <c>DiscreteAlarm</c> →
    ///     <see cref="DiscreteAlarmState"/>, and everything else (including <c>AlarmCondition</c> and
    ///     <c>LimitAlarm</c>, which has no script-supplied limits) → the base
    ///     <see cref="AlarmConditionState"/>. LimitAlarm deliberately falls back to base per the T13
    ///     notes — a script alarm carries no High/Low limits to populate.</para>
    /// </remarks>
    public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity)
    {
        ArgumentException.ThrowIfNullOrEmpty(alarmNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);

        lock (Lock)
        {
            // Idempotent: drop any prior node for this id so a re-materialise (e.g. changed
            // type/severity on redeploy) reflects cleanly instead of leaking the old node.
            if (_alarmConditions.TryRemove(alarmNodeId, out var existing))
            {
                existing.Parent?.RemoveChild(existing);
                PredefinedNodes?.Remove(existing.NodeId);
            }

            var parent = ResolveParentFolder(equipmentNodeId);

            AlarmConditionState alarm = CreateAlarmConditionOfType(alarmType, parent);
            alarm.SymbolicName = displayName;
            // HasComponent so the parent folder "owns" the condition (matches the T13 notes' pattern).
            alarm.ReferenceTypeId = ReferenceTypeIds.HasComponent;

            // Create builds the full mandatory Part 9 child set (EnabledState, AckedState,
            // ActiveState, the Acknowledge/Confirm/AddComment/Enable/Disable methods, ...) from the
            // type's embedded definition; we do not hand-build them.
            alarm.Create(
                SystemContext,
                new NodeId(alarmNodeId, NamespaceIndex),
                new QualifiedName(displayName, NamespaceIndex),
                new LocalizedText(displayName),
                assignNodeIds: true);

            // Main-branch id MUST be a concrete (null) NodeId before any Set* call: SetEnableState ->
            // UpdateRetainState -> GetRetainState -> IsBranch() dereferences BranchId.Value, which
            // Create leaves as a null reference and would NRE. NodeId.Null marks "the main branch".
            // (Real-server finding from the T14 integration test — not obvious from the SDK notes.)
            if (alarm.BranchId is not null) alarm.BranchId.Value = NodeId.Null;

            // Initial state via the SDK setters (T14: basic state only, NO event firing).
            alarm.SetEnableState(SystemContext, true);
            alarm.SetActiveState(SystemContext, false);
            alarm.SetAcknowledgedState(SystemContext, true);
            alarm.SetSeverity(SystemContext, MapSeverity(severity));
            alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
            alarm.Message.Value = new LocalizedText(displayName);
            if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;

            parent.AddChild(alarm);

            // Promote the equipment folder to an event notifier + register it as a root notifier so
            // T16's ReportEvent has a notifier path up to the Server object. Guard so repeated
            // materialise under the same folder doesn't double-add the root notifier.
            EnsureFolderIsEventNotifier(parent);

            AddPredefinedNode(SystemContext, alarm);
            _alarmConditions[alarmNodeId] = alarm;
        }
    }

    /// <summary>Map our domain <c>AlarmType</c> string to the matching SDK condition subtype. Script
    /// alarms have no OPC limit/setpoint values, so limit-style types fall back to the base
    /// <see cref="AlarmConditionState"/> (see <see cref="MaterialiseAlarmCondition"/> remarks).</summary>
    private static AlarmConditionState CreateAlarmConditionOfType(string alarmType, NodeState parent) => alarmType switch
    {
        "OffNormalAlarm" => new OffNormalAlarmState(parent),
        "DiscreteAlarm" => new DiscreteAlarmState(parent),
        // "LimitAlarm" / "AlarmCondition" / unknown ⇒ base: a script-driven alarm has no OPC limits
        // to populate, so the limit subtypes would carry unset High/Low children.
        _ => new AlarmConditionState(parent),
    };

    /// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
    /// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
    /// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
    private void EnsureFolderIsEventNotifier(FolderState folder)
    {
        if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
        folder.EventNotifier = EventNotifiers.SubscribeToEvents;
        AddRootNotifier(folder);
        folder.ClearChangeMasks(SystemContext, includeChildren: false);
    }

    /// <summary>Map an integer domain severity (treated as the OPC UA 1..1000 scale) onto the
    /// <see cref="EventSeverity"/> enum buckets the SDK's <c>SetSeverity</c> expects.</summary>
    private static EventSeverity MapSeverity(int severity) => severity switch
    {
        < 200 => EventSeverity.Low,
        < 400 => EventSeverity.MediumLow,
        < 600 => EventSeverity.Medium,
        < 800 => EventSeverity.MediumHigh,
        _ => EventSeverity.High,
    };

    /// <summary>
    ///     Ensure a folder node exists at <paramref name="folderNodeId"/> with the given display
    ///     name, parented under <paramref name="parentNodeId"/> (or the namespace root when null).
    ///     #85 — used by <see cref="Phase7Applier"/> to materialise the UNS Area/Line/Equipment
    ///     folder hierarchy. Idempotent: the second call with the same id returns the cached
    ///     folder so adding child variables under it still works.
    /// </summary>
    /// <param name="folderNodeId">The node identifier of the folder.</param>
    /// <param name="parentNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
    /// <param name="displayName">The display name of the folder.</param>
    public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName)
    {
        ArgumentException.ThrowIfNullOrEmpty(folderNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);

        if (_folders.ContainsKey(folderNodeId)) return;

        lock (Lock)
        {
            if (_folders.ContainsKey(folderNodeId)) return;

            var parent = ResolveParentFolder(parentNodeId);
            var folder = new FolderState(parent)
            {
                NodeId = new NodeId(folderNodeId, NamespaceIndex),
                BrowseName = new QualifiedName(folderNodeId, NamespaceIndex),
                DisplayName = displayName,
                EventNotifier = EventNotifiers.None,
                TypeDefinitionId = ObjectTypeIds.FolderType,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
            };
            parent.AddChild(folder);
            AddPredefinedNode(SystemContext, folder);
            _folders[folderNodeId] = folder;
        }
    }

    /// <summary>
    ///     Ensure a Variable node exists at <paramref name="variableNodeId"/> parented under
    ///     <paramref name="parentFolderNodeId"/> (or root when null). Initial value=null, quality=Bad,
    ///     timestamp=epoch — <see cref="WriteValue"/> fills these in once driver data flows.
    ///     Idempotent. Materialises Galaxy / SystemPlatform tags so they're browseable before the
    ///     Galaxy driver issues SubscribeBulk.
    /// </summary>
    /// <param name="variableNodeId">The node identifier of the variable.</param>
    /// <param name="parentFolderNodeId">The node identifier of the parent folder; null to use the namespace root.</param>
    /// <param name="displayName">The display name of the variable.</param>
    /// <param name="dataType">The OPC UA data type name (e.g., "Boolean", "Int32", "String").</param>
    public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType)
    {
        ArgumentException.ThrowIfNullOrEmpty(variableNodeId);
        ArgumentException.ThrowIfNullOrEmpty(displayName);

        // If already present, leave it alone (idempotent re-applies).
        if (_variables.ContainsKey(variableNodeId)) return;

        lock (Lock)
        {
            if (_variables.ContainsKey(variableNodeId)) return;

            var parent = ResolveParentFolder(parentFolderNodeId);
            var variable = new BaseDataVariableState(parent)
            {
                NodeId = new NodeId(variableNodeId, NamespaceIndex),
                BrowseName = new QualifiedName(variableNodeId, NamespaceIndex),
                DisplayName = displayName,
                TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
                DataType = ResolveBuiltInDataType(dataType),
                ValueRank = ValueRanks.Scalar,
                AccessLevel = AccessLevels.CurrentRead,
                UserAccessLevel = AccessLevels.CurrentRead,
                Historizing = false,
                Value = null,
                StatusCode = StatusCodes.BadWaitingForInitialData,
                Timestamp = DateTime.MinValue,
            };
            parent.AddChild(variable);
            AddPredefinedNode(SystemContext, variable);
            _variables[variableNodeId] = variable;
        }
    }

    /// <summary>Map a Tag.DataType string ("Boolean", "Int32", "Float", "Double", "String",
    /// "DateTime") to the OPC UA built-in NodeId. Unknown names fall back to BaseDataType
    /// (matches CreateVariable's default for lazy-created nodes).</summary>
    private static NodeId ResolveBuiltInDataType(string dataType) => dataType switch
    {
        "Boolean" => DataTypeIds.Boolean,
        "SByte" => DataTypeIds.SByte,
        "Byte" => DataTypeIds.Byte,
        "Int16" => DataTypeIds.Int16,
        "UInt16" => DataTypeIds.UInt16,
        "Int32" => DataTypeIds.Int32,
        "UInt32" => DataTypeIds.UInt32,
        "Int64" => DataTypeIds.Int64,
        "UInt64" => DataTypeIds.UInt64,
        "Float" => DataTypeIds.Float,
        "Double" => DataTypeIds.Double,
        "String" => DataTypeIds.String,
        "DateTime" => DataTypeIds.DateTime,
        _ => DataTypeIds.BaseDataType,
    };

    /// <summary>Clear every registered variable + folder from the address space. Phase7Applier
    /// calls this when Equipment/Alarm topology changes; the populator then re-adds via
    /// EnsureFolder + WriteValue on the next pass.</summary>
    public void RebuildAddressSpace()
    {
        lock (Lock)
        {
            foreach (var v in _variables.Values)
            {
                v.Parent?.RemoveChild(v);
                PredefinedNodes?.Remove(v.NodeId);
            }
            _variables.Clear();

            foreach (var alarm in _alarmConditions.Values)
            {
                alarm.Parent?.RemoveChild(alarm);
                PredefinedNodes?.Remove(alarm.NodeId);
            }
            _alarmConditions.Clear();

            foreach (var f in _folders.Values)
            {
                f.Parent?.RemoveChild(f);
                PredefinedNodes?.Remove(f.NodeId);
            }
            _folders.Clear();

            // Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the
            // guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server
            // object. RemoveRootNotifier just severs that link, so its order relative to the folder
            // teardown above doesn't matter — but it must run under this same Lock.
            foreach (var folder in _notifierFolders.Values)
            {
                RemoveRootNotifier(folder);
            }

            // Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
            // equipment folders to event notifiers.
            _notifierFolders.Clear();
        }
    }

    private FolderState ResolveParentFolder(string? parentNodeId)
    {
        if (string.IsNullOrEmpty(parentNodeId)) return _root!;
        return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
    }

    /// <inheritdoc />
    public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
    {
        lock (Lock)
        {
            base.CreateAddressSpace(externalReferences);

            // Create one root folder under Objects/ for every variable we mint to hang under.
            _root = new FolderState(null)
            {
                NodeId = new NodeId("OtOpcUa", NamespaceIndex),
                BrowseName = new QualifiedName("OtOpcUa", NamespaceIndex),
                DisplayName = "OtOpcUa",
                EventNotifier = EventNotifiers.None,
                TypeDefinitionId = ObjectTypeIds.FolderType,
            };
            _root.AddReference(ReferenceTypeIds.Organizes, isInverse: true, ObjectIds.ObjectsFolder);

            if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var refs))
            {
                refs = new List<IReference>();
                externalReferences[ObjectIds.ObjectsFolder] = refs;
            }
            refs.Add(new NodeStateReference(ReferenceTypeIds.Organizes, isInverse: false, _root.NodeId));

            AddPredefinedNode(SystemContext, _root);
        }
    }

    private BaseDataVariableState CreateVariable(string nodeId)
    {
        var v = new BaseDataVariableState(_root)
        {
            NodeId = new NodeId(nodeId, NamespaceIndex),
            BrowseName = new QualifiedName(nodeId, NamespaceIndex),
            DisplayName = nodeId,
            TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
            ReferenceTypeId = ReferenceTypeIds.Organizes,
            DataType = DataTypeIds.BaseDataType,
            ValueRank = ValueRanks.Scalar,
            AccessLevel = AccessLevels.CurrentRead,
            UserAccessLevel = AccessLevels.CurrentRead,
            Historizing = false,
        };
        _root?.AddChild(v);
        AddPredefinedNode(SystemContext, v);
        return v;
    }

    private static StatusCode StatusFromQuality(OpcUaQuality quality) => quality switch
    {
        OpcUaQuality.Good => StatusCodes.Good,
        OpcUaQuality.Uncertain => StatusCodes.Uncertain,
        _ => StatusCodes.Bad,
    };
}
