using System.Globalization;
using Microsoft.Extensions.Logging;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
using ZB.MOM.WW.OtOpcUa.Core.Resilience;
using ZB.MOM.WW.OtOpcUa.Server.Security;
using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest;
// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation
// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We
// assign driver-side results to an explicitly-aliased local and construct only the service
// type in the overrides below.
using OpcHistoryReadResult = Opc.Ua.HistoryReadResult;

namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa;

/// <summary>
///     Concrete <see cref="CustomNodeManager2"/> that materializes the driver's address space
///     into OPC UA nodes. Implements <see cref="IAddressSpaceBuilder"/> itself so
///     <c>GenericDriverNodeManager.BuildAddressSpaceAsync</c> can stream nodes directly into the
///     OPC UA server's namespace. PR 15's <c>MarkAsAlarmCondition</c> hook creates a sibling
///     <see cref="AlarmConditionState"/> node per alarm-flagged variable; subsequent driver
///     <c>OnAlarmEvent</c> pushes land through the returned sink to drive Activate /
///     Acknowledge / Deactivate transitions.
/// </summary>
/// <remarks>
///     Read / Subscribe / Write are routed to the driver's capability interfaces — the node
///     manager holds references to <see cref="IReadable"/>, <see cref="ISubscribable"/>, and
///     <see cref="IWritable"/> when present. Nodes with no driver backing (plain folders) are
///     served directly from the internal PredefinedNodes table.
/// </remarks>
public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder
{
    private readonly IDriver _driver;
    private readonly IReadable? _readable;
    private readonly IWritable? _writable;
    private readonly IPerCallHostResolver? _hostResolver;
    private readonly CapabilityInvoker _invoker;
    private readonly ILogger<DriverNodeManager> _logger;

    // Per-variable idempotency flag populated during Variable() registration from
    // DriverAttributeInfo.WriteIdempotent. Drives ExecuteWriteAsync's retry gating in
    // OnWriteValue; absent entries default to false (decisions #44, #45, #143).
    private readonly Dictionary<string, bool> _writeIdempotentByFullRef = new(StringComparer.OrdinalIgnoreCase);

    /// <summary>The driver whose address space this node manager exposes.</summary>
    public IDriver Driver => _driver;

    private FolderState? _driverRoot;
    private readonly Dictionary<string, BaseDataVariableState> _variablesByFullRef = new(StringComparer.OrdinalIgnoreCase);

    // PR 26: SecurityClassification per variable, populated during Variable() registration.
    // OnWriteValue looks up the classification here to gate the write by the session's roles.
    // Drivers never enforce authz themselves — the classification is discovery-time metadata
    // only (feedback_acl_at_server_layer.md).
    private readonly Dictionary<string, SecurityClassification> _securityByFullRef = new(StringComparer.OrdinalIgnoreCase);

    // Active building folder — set per Folder() call so Variable() lands under the right parent.
    // A stack would support nested folders; we use a single current folder because IAddressSpaceBuilder
    // returns a child builder per Folder call and the caller threads nesting through those references.
    private FolderState _currentFolder = null!;

    // Phase 6.2 Stream C follow-up — optional gate + scope resolver. When both are null
    // the old pre-Phase-6.2 dispatch path runs unchanged (backwards compat for every
    // integration test that constructs DriverNodeManager without the gate). When wired,
    // OnReadValue / OnWriteValue / HistoryRead all consult the gate before the invoker call.
    private readonly AuthorizationGate? _authzGate;
    private readonly NodeScopeResolver? _scopeResolver;

    // Phase 7 Stream G follow-up — per-variable NodeSourceKind so OnReadValue can dispatch
    // to the VirtualTagEngine / ScriptedAlarmEngine instead of the driver's IReadable per
    // ADR-002. Absent entries default to Driver so drivers registered before Phase 7
    // keep working unchanged.
    private readonly Dictionary<string, NodeSourceKind> _sourceByFullRef = new(StringComparer.OrdinalIgnoreCase);
    private readonly IReadable? _virtualReadable;
    private readonly IReadable? _scriptedAlarmReadable;

    public DriverNodeManager(IServerInternal server, ApplicationConfiguration configuration,
        IDriver driver, CapabilityInvoker invoker, ILogger<DriverNodeManager> logger,
        AuthorizationGate? authzGate = null, NodeScopeResolver? scopeResolver = null,
        IReadable? virtualReadable = null, IReadable? scriptedAlarmReadable = null)
        : base(server, configuration, namespaceUris: $"urn:OtOpcUa:{driver.DriverInstanceId}")
    {
        _driver = driver;
        _readable = driver as IReadable;
        _writable = driver as IWritable;
        _hostResolver = driver as IPerCallHostResolver;
        _invoker = invoker;
        _authzGate = authzGate;
        _scopeResolver = scopeResolver;
        _virtualReadable = virtualReadable;
        _scriptedAlarmReadable = scriptedAlarmReadable;
        _logger = logger;
    }

    protected override NodeStateCollection LoadPredefinedNodes(ISystemContext context) => new();

    /// <summary>
    ///     Resolve the host name fed to the Phase 6.1 CapabilityInvoker for a per-tag call.
    ///     Multi-host drivers that implement <see cref="IPerCallHostResolver"/> get their
    ///     per-PLC isolation (decision #144); single-host drivers + drivers that don't
    ///     implement the resolver fall back to the DriverInstanceId — preserves existing
    ///     Phase 6.1 pipeline-key semantics for those drivers.
    /// </summary>
    private string ResolveHostFor(string fullReference)
    {
        if (_hostResolver is null) return _driver.DriverInstanceId;

        var resolved = _hostResolver.ResolveHost(fullReference);
        return string.IsNullOrWhiteSpace(resolved) ? _driver.DriverInstanceId : resolved;
    }

    public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
    {
        lock (Lock)
        {
            _driverRoot = new FolderState(null)
            {
                SymbolicName = _driver.DriverInstanceId,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
                TypeDefinitionId = ObjectTypeIds.FolderType,
                NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex),
                BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex),
                DisplayName = new LocalizedText(_driver.DriverInstanceId),
                // Driver root is the conventional event notifier for HistoryReadEvents — clients
                // request alarm history by targeting it and the node manager routes through
                // IHistoryProvider.ReadEventsAsync. SubscribeToEvents is also set so live-event
                // subscriptions (Alarm & Conditions) can point here in a future PR; today the
                // alarm events are emitted by per-variable AlarmConditionState siblings but a
                // "subscribe to all events from this driver" path would use this notifier.
                EventNotifier = (byte)(EventNotifiers.SubscribeToEvents | EventNotifiers.HistoryRead),
            };

            // Link under Objects folder so clients see the driver subtree at browse root.
            if (!externalReferences.TryGetValue(ObjectIds.ObjectsFolder, out var references))
            {
                references = new List<IReference>();
                externalReferences[ObjectIds.ObjectsFolder] = references;
            }
            references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, _driverRoot.NodeId));

            AddPredefinedNode(SystemContext, _driverRoot);
            _currentFolder = _driverRoot;
        }
    }

    // ------- IAddressSpaceBuilder implementation (PR 15 contract) -------

    public IAddressSpaceBuilder Folder(string browseName, string displayName)
    {
        lock (Lock)
        {
            var folder = new FolderState(_currentFolder)
            {
                SymbolicName = browseName,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
                TypeDefinitionId = ObjectTypeIds.FolderType,
                NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
                BrowseName = new QualifiedName(browseName, NamespaceIndex),
                DisplayName = new LocalizedText(displayName),
            };
            _currentFolder.AddChild(folder);
            AddPredefinedNode(SystemContext, folder);
            return new NestedBuilder(this, folder);
        }
    }

    public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
    {
        lock (Lock)
        {
            var v = new BaseDataVariableState(_currentFolder)
            {
                SymbolicName = browseName,
                ReferenceTypeId = ReferenceTypeIds.Organizes,
                TypeDefinitionId = VariableTypeIds.BaseDataVariableType,
                NodeId = new NodeId(attributeInfo.FullName, NamespaceIndex),
                BrowseName = new QualifiedName(browseName, NamespaceIndex),
                DisplayName = new LocalizedText(displayName),
                DataType = MapDataType(attributeInfo.DriverDataType),
                ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar,
                // Historized attributes get the HistoryRead access bit so the stack dispatches
                // incoming HistoryRead service calls to this node. Without it the base class
                // returns BadHistoryOperationUnsupported before our per-kind hook ever runs.
                // HistoryWrite isn't granted — history rewrite is a separate capability the
                // driver doesn't support today.
                AccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
                    | (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
                UserAccessLevel = (byte)(AccessLevels.CurrentReadOrWrite
                    | (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)),
                Historizing = attributeInfo.IsHistorized,
            };
            _currentFolder.AddChild(v);
            AddPredefinedNode(SystemContext, v);
            _variablesByFullRef[attributeInfo.FullName] = v;
            _securityByFullRef[attributeInfo.FullName] = attributeInfo.SecurityClass;
            _writeIdempotentByFullRef[attributeInfo.FullName] = attributeInfo.WriteIdempotent;
            _sourceByFullRef[attributeInfo.FullName] = attributeInfo.Source;

            v.OnReadValue = OnReadValue;
            v.OnWriteValue = OnWriteValue;
            return new VariableHandle(this, v, attributeInfo.FullName);
        }
    }

    public void AddProperty(string browseName, DriverDataType dataType, object? value)
    {
        lock (Lock)
        {
            var p = new PropertyState(_currentFolder)
            {
                SymbolicName = browseName,
                ReferenceTypeId = ReferenceTypeIds.HasProperty,
                TypeDefinitionId = VariableTypeIds.PropertyType,
                NodeId = new NodeId($"{_currentFolder.NodeId.Identifier}/{browseName}", NamespaceIndex),
                BrowseName = new QualifiedName(browseName, NamespaceIndex),
                DisplayName = new LocalizedText(browseName),
                DataType = MapDataType(dataType),
                ValueRank = ValueRanks.Scalar,
                Value = value,
            };
            _currentFolder.AddChild(p);
            AddPredefinedNode(SystemContext, p);
        }
    }

    private ServiceResult OnReadValue(ISystemContext context, NodeState node, NumericRange indexRange,
        QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
    {
        var fullRef = node.NodeId.Identifier as string ?? "";
        var source = _sourceByFullRef.TryGetValue(fullRef, out var s) ? s : NodeSourceKind.Driver;
        var readable = SelectReadable(source, _readable, _virtualReadable, _scriptedAlarmReadable);

        if (readable is null)
        {
            statusCode = source == NodeSourceKind.Driver ? StatusCodes.BadNotReadable : StatusCodes.BadNotFound;
            return ServiceResult.Good;
        }

        try
        {
            // Phase 6.2 Stream C — authorization gate. Runs ahead of the invoker so a denied
            // read never hits the driver. Returns true in lax mode when identity lacks LDAP
            // groups; strict mode denies those cases. See AuthorizationGate remarks.
            if (_authzGate is not null && _scopeResolver is not null)
            {
                var scope = _scopeResolver.Resolve(fullRef);
                if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.Read, scope))
                {
                    statusCode = StatusCodes.BadUserAccessDenied;
                    return ServiceResult.Good;
                }
            }

            var result = _invoker.ExecuteAsync(
                DriverCapability.Read,
                ResolveHostFor(fullRef),
                async ct => (IReadOnlyList<DataValueSnapshot>)await readable.ReadAsync([fullRef], ct).ConfigureAwait(false),
                CancellationToken.None).AsTask().GetAwaiter().GetResult();
            if (result.Count == 0)
            {
                statusCode = StatusCodes.BadNoData;
                return ServiceResult.Good;
            }
            var snap = result[0];
            value = snap.Value;
            statusCode = snap.StatusCode;
            timestamp = snap.ServerTimestampUtc;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "OnReadValue failed for {NodeId}", node.NodeId);
            statusCode = StatusCodes.BadInternalError;
        }
        return ServiceResult.Good;
    }

    /// <summary>
    ///     Picks the <see cref="IReadable"/> the dispatch layer routes through based on the
    ///     node's Phase 7 source kind (ADR-002). Extracted as a pure function for unit test
    ///     coverage — the full dispatch requires the OPC UA server stack, but this kernel is
    ///     deterministic and small.
    /// </summary>
    internal static IReadable? SelectReadable(
        NodeSourceKind source,
        IReadable? driverReadable,
        IReadable? virtualReadable,
        IReadable? scriptedAlarmReadable) => source switch
    {
        NodeSourceKind.Virtual => virtualReadable,
        NodeSourceKind.ScriptedAlarm => scriptedAlarmReadable,
        _ => driverReadable,
    };

    /// <summary>
    ///     Plan decision #6 gate — returns true only when the write is allowed. Virtual tags
    ///     and scripted alarms reject OPC UA writes because the write path for virtual tags
    ///     is <c>ctx.SetVirtualTag</c> from within a script, and the write path for alarm
    ///     state is the Part 9 method nodes (Acknowledge / Confirm / Shelve).
    /// </summary>
    internal static bool IsWriteAllowedBySource(NodeSourceKind source) =>
        source == NodeSourceKind.Driver;

    private static NodeId MapDataType(DriverDataType t) => t switch
    {
        DriverDataType.Boolean => DataTypeIds.Boolean,
        DriverDataType.Int32 => DataTypeIds.Int32,
        DriverDataType.Float32 => DataTypeIds.Float,
        DriverDataType.Float64 => DataTypeIds.Double,
        DriverDataType.String => DataTypeIds.String,
        DriverDataType.DateTime => DataTypeIds.DateTime,
        _ => DataTypeIds.BaseDataType,
    };

    /// <summary>
    ///     Nested builder returned by <see cref="Folder"/>. Temporarily retargets the parent's
    ///     <see cref="_currentFolder"/> during each call so Variable/Folder calls land under the
    ///     correct subtree. Not thread-safe if callers drive Discovery concurrently — but
    ///     <c>GenericDriverNodeManager</c> discovery is sequential per driver.
    /// </summary>
    private sealed class NestedBuilder(DriverNodeManager owner, FolderState folder) : IAddressSpaceBuilder
    {
        public IAddressSpaceBuilder Folder(string browseName, string displayName)
        {
            var prior = owner._currentFolder;
            owner._currentFolder = folder;
            try { return owner.Folder(browseName, displayName); }
            finally { owner._currentFolder = prior; }
        }

        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
        {
            var prior = owner._currentFolder;
            owner._currentFolder = folder;
            try { return owner.Variable(browseName, displayName, attributeInfo); }
            finally { owner._currentFolder = prior; }
        }

        public void AddProperty(string browseName, DriverDataType dataType, object? value)
        {
            var prior = owner._currentFolder;
            owner._currentFolder = folder;
            try { owner.AddProperty(browseName, dataType, value); }
            finally { owner._currentFolder = prior; }
        }
    }

    private sealed class VariableHandle : IVariableHandle
    {
        private readonly DriverNodeManager _owner;
        private readonly BaseDataVariableState _variable;
        public string FullReference { get; }

        public VariableHandle(DriverNodeManager owner, BaseDataVariableState variable, string fullRef)
        {
            _owner = owner;
            _variable = variable;
            FullReference = fullRef;
        }

        public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info)
        {
            lock (_owner.Lock)
            {
                var alarm = new AlarmConditionState(_variable)
                {
                    SymbolicName = _variable.BrowseName.Name + "_Condition",
                    ReferenceTypeId = ReferenceTypeIds.HasComponent,
                    NodeId = new NodeId(FullReference + ".Condition", _owner.NamespaceIndex),
                    BrowseName = new QualifiedName(_variable.BrowseName.Name + "_Condition", _owner.NamespaceIndex),
                    DisplayName = new LocalizedText(info.SourceName),
                };
                // assignNodeIds=true makes the stack allocate NodeIds for every inherited
                // AlarmConditionState child (Severity / Message / ActiveState / AckedState /
                // EnabledState / …). Without this the children keep Foundation (ns=0) type-
                // declaration NodeIds that aren't in the node manager's predefined-node index.
                // The newly-allocated NodeIds default to ns=0 via the shared identifier
                // counter — we remap them to the node manager's namespace below so client
                // Read/Browse on children resolves against the predefined-node dictionary.
                alarm.Create(_owner.SystemContext, alarm.NodeId, alarm.BrowseName, alarm.DisplayName, true);
                // Assign every descendant a stable, collision-free NodeId in the node manager's
                // namespace keyed on the condition path. The stack's default assignNodeIds path
                // allocates from a shared ns=0 counter and does not update parent→child
                // references when we remap, so we do the rename up front, symbolically:
                //   {condition-full-ref}/{symbolic-path-under-condition}
                AssignSymbolicDescendantIds(alarm, alarm.NodeId, _owner.NamespaceIndex);
                alarm.SourceName.Value = info.SourceName;
                alarm.Severity.Value = (ushort)MapSeverity(info.InitialSeverity);
                alarm.Message.Value = new LocalizedText(info.InitialDescription ?? info.SourceName);
                alarm.EnabledState.Value = new LocalizedText("Enabled");
                alarm.EnabledState.Id.Value = true;
                alarm.Retain.Value = false;
                alarm.AckedState.Value = new LocalizedText("Acknowledged");
                alarm.AckedState.Id.Value = true;
                alarm.ActiveState.Value = new LocalizedText("Inactive");
                alarm.ActiveState.Id.Value = false;
                // Enable ConditionRefresh support so clients that connect *after* a transition
                // can pull the current retained-condition snapshot.
                alarm.ClientUserId.Value = string.Empty;
                alarm.BranchId.Value = NodeId.Null;

                _variable.AddChild(alarm);
                _owner.AddPredefinedNode(_owner.SystemContext, alarm);

                // Part 9 event propagation: AddRootNotifier registers the alarm as an event
                // source reachable from Objects/Server so subscriptions placed on Server-object
                // EventNotifier receive the ReportEvent calls ConditionSink.OnTransition emits.
                // Without this the Report fires but has no subscribers to deliver to.
                _owner.AddRootNotifier(alarm);

                return new ConditionSink(_owner, alarm);
            }
        }

        private static int MapSeverity(AlarmSeverity s) => s switch
        {
            AlarmSeverity.Low => 250,
            AlarmSeverity.Medium => 500,
            AlarmSeverity.High => 700,
            AlarmSeverity.Critical => 900,
            _ => 500,
        };

        // After alarm.Create(assignNodeIds=true), every descendant has *some* NodeId but
        // they default to ns=0 via the shared identifier counter — allocations from two
        // different alarms collide when we move them into the driver's namespace. Rewriting
        // symbolically based on the condition path gives each descendant a unique, stable
        // NodeId in the node manager's namespace. Browse + Read resolve against the current
        // NodeId because the stack's CustomNodeManager2.Browse traverses NodeState.Children
        // (NodeState references) and uses each child's current .NodeId in the response.
        private static void AssignSymbolicDescendantIds(
            NodeState parent, NodeId parentNodeId, ushort namespaceIndex)
        {
            var children = new List<BaseInstanceState>();
            parent.GetChildren(null!, children);
            foreach (var child in children)
            {
                child.NodeId = new NodeId(
                    $"{parentNodeId.Identifier}.{child.SymbolicName}", namespaceIndex);
                AssignSymbolicDescendantIds(child, child.NodeId, namespaceIndex);
            }
        }
    }

    private sealed class ConditionSink(DriverNodeManager owner, AlarmConditionState alarm)
        : IAlarmConditionSink
    {
        public void OnTransition(AlarmEventArgs args)
        {
            lock (owner.Lock)
            {
                alarm.Severity.Value = (ushort)MapSeverity(args.Severity);
                alarm.Time.Value = args.SourceTimestampUtc;
                alarm.Message.Value = new LocalizedText(args.Message);

                // Map the driver's transition type to OPC UA Part 9 state. The driver uses
                // AlarmEventArgs but the state transition kind is encoded in AlarmType by
                // convention — Galaxy's GalaxyAlarmTracker emits "Active"/"Acknowledged"/"Inactive".
                switch (args.AlarmType)
                {
                    case "Active":
                        alarm.SetActiveState(owner.SystemContext, true);
                        alarm.SetAcknowledgedState(owner.SystemContext, false);
                        alarm.Retain.Value = true;
                        break;
                    case "Acknowledged":
                        alarm.SetAcknowledgedState(owner.SystemContext, true);
                        break;
                    case "Inactive":
                        alarm.SetActiveState(owner.SystemContext, false);
                        // Retain stays true until the condition is both Inactive and Acknowledged
                        // so alarm clients keep the record in their condition refresh snapshot.
                        if (alarm.AckedState.Id.Value) alarm.Retain.Value = false;
                        break;
                }

                alarm.ClearChangeMasks(owner.SystemContext, true);
                alarm.ReportEvent(owner.SystemContext, alarm);
            }
        }

        private static int MapSeverity(AlarmSeverity s) => s switch
        {
            AlarmSeverity.Low => 250,
            AlarmSeverity.Medium => 500,
            AlarmSeverity.High => 700,
            AlarmSeverity.Critical => 900,
            _ => 500,
        };
    }

    /// <summary>
    ///     Per-variable write hook wired on each <see cref="BaseDataVariableState"/>. Routes the
    ///     value into the driver's <see cref="IWritable"/> and surfaces its per-tag status code.
    /// </summary>
    private ServiceResult OnWriteValue(ISystemContext context, NodeState node, NumericRange indexRange,
        QualifiedName dataEncoding, ref object? value, ref StatusCode statusCode, ref DateTime timestamp)
    {
        var fullRef = node.NodeId.Identifier as string;
        if (string.IsNullOrEmpty(fullRef)) return StatusCodes.BadNodeIdUnknown;

        // Per Phase 7 plan decision #6 — virtual tags + scripted alarms reject direct
        // OPC UA writes with BadUserAccessDenied. Scripts can write to virtual tags
        // via ctx.SetVirtualTag; operators cannot. Operator alarm actions go through
        // the Part 9 method nodes (Acknowledge / Confirm / Shelve), not through the
        // variable-value write path.
        if (_sourceByFullRef.TryGetValue(fullRef!, out var source) && !IsWriteAllowedBySource(source))
            return new ServiceResult(StatusCodes.BadUserAccessDenied);

        if (_writable is null) return StatusCodes.BadNotWritable;

        // PR 26: server-layer write authorization. Look up the attribute's classification
        // (populated during Variable() in Discover) and check the session's roles against the
        // policy table. Drivers don't participate in this decision — IWritable.WriteAsync
        // never sees a request we'd have refused here.
        if (_securityByFullRef.TryGetValue(fullRef!, out var classification))
        {
            var roles = context.UserIdentity is IRoleBearer rb ? rb.Roles : [];
            if (!WriteAuthzPolicy.IsAllowed(classification, roles))
            {
                _logger.LogInformation(
                    "Write denied for {FullRef}: classification={Classification} userRoles=[{Roles}]",
                    fullRef, classification, string.Join(",", roles));
                return new ServiceResult(StatusCodes.BadUserAccessDenied);
            }

            // Phase 6.2 Stream C — additive gate check. The classification/role check above
            // is the pre-Phase-6.2 baseline; the gate adds per-tag ACL enforcement on top. In
            // lax mode (default during rollout) the gate falls through when the identity
            // lacks LDAP groups, so existing integration tests keep passing.
            if (_authzGate is not null && _scopeResolver is not null)
            {
                var scope = _scopeResolver.Resolve(fullRef!);
                var writeOp = WriteAuthzPolicy.ToOpcUaOperation(classification);
                if (!_authzGate.IsAllowed(context.UserIdentity, writeOp, scope))
                {
                    _logger.LogInformation(
                        "Write denied by ACL gate for {FullRef}: operation={Op} classification={Classification}",
                        fullRef, writeOp, classification);
                    return new ServiceResult(StatusCodes.BadUserAccessDenied);
                }
            }
        }

        try
        {
            var isIdempotent = _writeIdempotentByFullRef.GetValueOrDefault(fullRef!, false);
            var capturedValue = value;
            var results = _invoker.ExecuteWriteAsync(
                ResolveHostFor(fullRef!),
                isIdempotent,
                async ct => (IReadOnlyList<WriteResult>)await _writable.WriteAsync(
                    [new DriverWriteRequest(fullRef!, capturedValue)],
                    ct).ConfigureAwait(false),
                CancellationToken.None).AsTask().GetAwaiter().GetResult();
            if (results.Count > 0 && results[0].StatusCode != 0)
            {
                statusCode = results[0].StatusCode;
                return ServiceResult.Good;
            }
            return ServiceResult.Good;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Write failed for {FullRef}", fullRef);
            return new ServiceResult(StatusCodes.BadInternalError);
        }
    }

    // Diagnostics hook for tests — number of variables registered in this node manager.
    internal int VariableCount => _variablesByFullRef.Count;
    internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v)
        => _variablesByFullRef.TryGetValue(fullRef, out v!);

    // ===================== HistoryRead service handlers (LMX #1, PR 38) =====================
    //
    // Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync
    // alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service.
    // CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one
    // based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the
    // per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern
    // OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a
    // different sync-over-async convention.
    //
    // Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in
    // nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference
    // (set during Variable() registration) so we can dispatch straight to IHistoryProvider
    // without a second lookup. Nodes without IHistoryProvider backing (drivers that don't
    // implement the capability) surface BadHistoryOperationUnsupported per slot and the
    // rest of the batch continues — same failure-isolation pattern as OnWriteValue.
    //
    // Continuation-point handling is pass-through only in this PR: the driver returns null
    // from its ContinuationPoint field today so the outer result's ContinuationPoint stays
    // empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver
    // actually needs paging — the dispatch shape doesn't change, only the result-population.

    private IHistoryProvider? History => _driver as IHistoryProvider;

    protected override void HistoryReadRawModified(
        ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps,
        IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
        IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        if (History is null)
        {
            MarkAllUnsupported(nodesToProcess, results, errors);
            return;
        }

        // IsReadModified=true requests a "modifications" history (who changed the data, when
        // it was re-written). The driver side has no modifications store — surface that
        // explicitly rather than silently returning raw data, which would mislead the client.
        if (details.IsReadModified)
        {
            MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadHistoryOperationUnsupported);
            return;
        }

        for (var n = 0; n < nodesToProcess.Count; n++)
        {
            var handle = nodesToProcess[n];
            // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
            // arrays. nodesToProcess is the filtered subset (just the nodes this manager
            // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
            // are interleaved across multiple node managers.
            var i = handle.Index;
            var fullRef = ResolveFullRef(handle);
            if (fullRef is null)
            {
                WriteNodeIdUnknown(results, errors, i);
                continue;
            }

            if (_authzGate is not null && _scopeResolver is not null)
            {
                var historyScope = _scopeResolver.Resolve(fullRef);
                if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
                {
                    WriteAccessDenied(results, errors, i);
                    continue;
                }
            }

            try
            {
                var driverResult = _invoker.ExecuteAsync(
                    DriverCapability.HistoryRead,
                    ResolveHostFor(fullRef),
                    async ct => await History.ReadRawAsync(
                        fullRef,
                        details.StartTime,
                        details.EndTime,
                        details.NumValuesPerNode,
                        ct).ConfigureAwait(false),
                    CancellationToken.None).AsTask().GetAwaiter().GetResult();

                WriteResult(results, errors, i, StatusCodes.Good,
                    BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
            }
            catch (NotSupportedException)
            {
                WriteUnsupported(results, errors, i);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "HistoryReadRaw failed for {FullRef}", fullRef);
                WriteInternalError(results, errors, i);
            }
        }
    }

    protected override void HistoryReadProcessed(
        ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestamps,
        IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
        IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        if (History is null)
        {
            MarkAllUnsupported(nodesToProcess, results, errors);
            return;
        }

        // AggregateType is one NodeId shared across every item in the batch — map once.
        var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault());
        if (aggregate is null)
        {
            MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadAggregateNotSupported);
            return;
        }

        var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval);
        for (var n = 0; n < nodesToProcess.Count; n++)
        {
            var handle = nodesToProcess[n];
            // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
            // arrays. nodesToProcess is the filtered subset (just the nodes this manager
            // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
            // are interleaved across multiple node managers.
            var i = handle.Index;
            var fullRef = ResolveFullRef(handle);
            if (fullRef is null)
            {
                WriteNodeIdUnknown(results, errors, i);
                continue;
            }

            if (_authzGate is not null && _scopeResolver is not null)
            {
                var historyScope = _scopeResolver.Resolve(fullRef);
                if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
                {
                    WriteAccessDenied(results, errors, i);
                    continue;
                }
            }

            try
            {
                var driverResult = _invoker.ExecuteAsync(
                    DriverCapability.HistoryRead,
                    ResolveHostFor(fullRef),
                    async ct => await History.ReadProcessedAsync(
                        fullRef,
                        details.StartTime,
                        details.EndTime,
                        interval,
                        aggregate.Value,
                        ct).ConfigureAwait(false),
                    CancellationToken.None).AsTask().GetAwaiter().GetResult();

                WriteResult(results, errors, i, StatusCodes.Good,
                    BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
            }
            catch (NotSupportedException)
            {
                WriteUnsupported(results, errors, i);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "HistoryReadProcessed failed for {FullRef}", fullRef);
                WriteInternalError(results, errors, i);
            }
        }
    }

    protected override void HistoryReadAtTime(
        ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestamps,
        IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
        IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        if (History is null)
        {
            MarkAllUnsupported(nodesToProcess, results, errors);
            return;
        }

        var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
        for (var n = 0; n < nodesToProcess.Count; n++)
        {
            var handle = nodesToProcess[n];
            // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
            // arrays. nodesToProcess is the filtered subset (just the nodes this manager
            // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
            // are interleaved across multiple node managers.
            var i = handle.Index;
            var fullRef = ResolveFullRef(handle);
            if (fullRef is null)
            {
                WriteNodeIdUnknown(results, errors, i);
                continue;
            }

            if (_authzGate is not null && _scopeResolver is not null)
            {
                var historyScope = _scopeResolver.Resolve(fullRef);
                if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
                {
                    WriteAccessDenied(results, errors, i);
                    continue;
                }
            }

            try
            {
                var driverResult = _invoker.ExecuteAsync(
                    DriverCapability.HistoryRead,
                    ResolveHostFor(fullRef),
                    async ct => await History.ReadAtTimeAsync(fullRef, requestedTimes, ct).ConfigureAwait(false),
                    CancellationToken.None).AsTask().GetAwaiter().GetResult();

                WriteResult(results, errors, i, StatusCodes.Good,
                    BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint);
            }
            catch (NotSupportedException)
            {
                WriteUnsupported(results, errors, i);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "HistoryReadAtTime failed for {FullRef}", fullRef);
                WriteInternalError(results, errors, i);
            }
        }
    }

    protected override void HistoryReadEvents(
        ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestamps,
        IList<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
        IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
        IDictionary<NodeId, NodeState> cache)
    {
        if (History is null)
        {
            MarkAllUnsupported(nodesToProcess, results, errors);
            return;
        }

        // SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause
        // handling is a dedicated concern (proper per-select-clause Variant population + where
        // filter evaluation). This PR treats the event query as "all events in range for the
        // node's source" and populates only the standard BaseEventType fields. Richer filter
        // handling is a follow-up; clients issuing empty/default filters get the right answer
        // today which covers the common alarm-history browse case.
        var maxEvents = (int)details.NumValuesPerNode;
        if (maxEvents <= 0) maxEvents = 1000;

        for (var n = 0; n < nodesToProcess.Count; n++)
        {
            var handle = nodesToProcess[n];
            // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead
            // arrays. nodesToProcess is the filtered subset (just the nodes this manager
            // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes
            // are interleaved across multiple node managers.
            var i = handle.Index;
            // Event history queries may target a notifier object (e.g. the driver-root folder)
            // rather than a specific variable — in that case we pass sourceName=null to mean
            // "all sources in the driver's namespace" per the IHistoryProvider contract.
            var fullRef = ResolveFullRef(handle);

            // fullRef is null for event-history queries that target a notifier (driver root).
            // Those are cluster-wide reads + need a different scope shape; skip the gate here
            // and let the driver-level authz handle them. Non-null path gets per-node gating.
            if (fullRef is not null && _authzGate is not null && _scopeResolver is not null)
            {
                var historyScope = _scopeResolver.Resolve(fullRef);
                if (!_authzGate.IsAllowed(context.UserIdentity, OpcUaOperation.HistoryRead, historyScope))
                {
                    WriteAccessDenied(results, errors, i);
                    continue;
                }
            }

            try
            {
                var driverResult = _invoker.ExecuteAsync(
                    DriverCapability.HistoryRead,
                    fullRef is null ? _driver.DriverInstanceId : ResolveHostFor(fullRef),
                    async ct => await History.ReadEventsAsync(
                        sourceName: fullRef,
                        startUtc: details.StartTime,
                        endUtc: details.EndTime,
                        maxEvents: maxEvents,
                        cancellationToken: ct).ConfigureAwait(false),
                    CancellationToken.None).AsTask().GetAwaiter().GetResult();

                WriteResult(results, errors, i, StatusCodes.Good,
                    BuildHistoryEvent(driverResult.Events), driverResult.ContinuationPoint);
            }
            catch (NotSupportedException)
            {
                WriteUnsupported(results, errors, i);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex, "HistoryReadEvents failed for {FullRef}", fullRef);
                WriteInternalError(results, errors, i);
            }
        }
    }

    private string? ResolveFullRef(NodeHandle handle) => handle.NodeId?.Identifier as string;

    // Both the results list AND the parallel errors list must be populated — MasterNodeManager
    // merges them and the merged StatusCode is what the client sees. Leaving errors[i] at its
    // default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported, which
    // masks a correctly-constructed HistoryData response. This was the subtle failure mode
    // that cost most of PR 38's debugging budget.
    private static void WriteResult(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors,
        int i, uint statusCode, ExtensionObject historyData, byte[]? continuationPoint)
    {
        results[i] = new OpcHistoryReadResult
        {
            StatusCode = statusCode,
            HistoryData = historyData,
            ContinuationPoint = continuationPoint,
        };
        errors[i] = statusCode == StatusCodes.Good
            ? ServiceResult.Good
            : new ServiceResult(statusCode);
    }

    private static void WriteUnsupported(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
    {
        results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
        errors[i] = StatusCodes.BadHistoryOperationUnsupported;
    }

    private static void WriteInternalError(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
    {
        results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadInternalError };
        errors[i] = StatusCodes.BadInternalError;
    }

    private static void WriteAccessDenied(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
    {
        results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadUserAccessDenied };
        errors[i] = StatusCodes.BadUserAccessDenied;
    }

    private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
    {
        WriteNodeIdUnknown(results, errors, i);
        errors[i] = StatusCodes.BadNodeIdUnknown;
    }

    private static void MarkAllUnsupported(
        List<NodeHandle> nodes, IList<OpcHistoryReadResult> results, IList<ServiceResult> errors,
        uint statusCode = StatusCodes.BadHistoryOperationUnsupported)
    {
        foreach (var handle in nodes)
        {
            results[handle.Index] = new OpcHistoryReadResult { StatusCode = statusCode };
            errors[handle.Index] = statusCode == StatusCodes.Good ? ServiceResult.Good : new ServiceResult(statusCode);
        }
    }

    /// <summary>
    ///     Map the OPC UA Part 13 aggregate-function NodeId to the driver's
    ///     <see cref="HistoryAggregateType"/>. Internal so the test suite can pin the mapping
    ///     without exposing public API. Returns null for unsupported aggregates so the service
    ///     handler can surface <c>BadAggregateNotSupported</c> on the whole batch.
    /// </summary>
    internal static HistoryAggregateType? MapAggregate(NodeId? aggregateNodeId)
    {
        if (aggregateNodeId is null) return null;

        // Every AggregateFunction_* identifier is a numeric uint on the Server (0) namespace.
        // Comparing NodeIds by value handles all the cross-encoding cases (expanded vs plain).
        if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total;
        if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count;
        return null;
    }

    /// <summary>
    ///     Wrap driver samples as <c>HistoryData</c> in an <c>ExtensionObject</c> — the on-wire
    ///     shape the OPC UA HistoryRead service expects for raw / processed / at-time reads.
    /// </summary>
    internal static ExtensionObject BuildHistoryData(IReadOnlyList<DataValueSnapshot> samples)
    {
        var values = new DataValueCollection(samples.Count);
        foreach (var s in samples) values.Add(ToDataValue(s));
        return new ExtensionObject(new HistoryData { DataValues = values });
    }

    /// <summary>
    ///     Wrap driver events as <c>HistoryEvent</c> in an <c>ExtensionObject</c>. Populates
    ///     the minimum BaseEventType field set (SourceName, Message, Severity, Time,
    ///     ReceiveTime, EventId) so clients that request the default
    ///     <c>SimpleAttributeOperand</c> select-clauses see useful data. Custom EventFilter
    ///     SelectClause evaluation is deferred — when a client sends a specific operand list,
    ///     they currently get the standard fields back and ignore the extras. Documented on the
    ///     public follow-up list.
    /// </summary>
    internal static ExtensionObject BuildHistoryEvent(IReadOnlyList<HistoricalEvent> events)
    {
        var fieldLists = new HistoryEventFieldListCollection(events.Count);
        foreach (var e in events)
        {
            var fields = new VariantCollection
            {
                // Order must match BaseEventType's conventional field ordering so clients that
                // didn't customize the SelectClauses still see recognizable columns. A future
                // PR that respects the client's SelectClause list will drive this from the filter.
                new Variant(e.EventId),
                new Variant(e.SourceName ?? string.Empty),
                new Variant(new LocalizedText(e.Message ?? string.Empty)),
                new Variant(e.Severity),
                new Variant(e.EventTimeUtc),
                new Variant(e.ReceivedTimeUtc),
            };
            fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
        }
        return new ExtensionObject(new HistoryEvent { Events = fieldLists });
    }

    internal static DataValue ToDataValue(DataValueSnapshot s)
    {
        var dv = new DataValue
        {
            Value = s.Value,
            StatusCode = new StatusCode(s.StatusCode),
            ServerTimestamp = s.ServerTimestampUtc,
        };
        if (s.SourceTimestampUtc.HasValue) dv.SourceTimestamp = s.SourceTimestampUtc.Value;
        return dv;
    }
}
