using Akka.Actor;
using Microsoft.CodeAnalysis.Scripting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;
using System.Text.Json;

namespace ZB.MOM.WW.ScadaBridge.SiteRuntime.Actors;

/// <summary>
/// Represents a single deployed instance at runtime. Holds the in-memory attribute state
/// (loaded from FlattenedConfiguration + static overrides from SQLite).
///
/// The Instance Actor is the single source of truth for runtime instance state.
/// All state mutations are serialized through the actor mailbox.
///         Multiple Script Execution Actors run concurrently; state mutations through this actor.
///
/// Creates child Script Actors and Alarm Actors on startup.
/// Tell for tag value updates, attribute notifications, stream publishing.
///        Ask for CallScript, debug snapshot.
/// Debug view backend — snapshot + stream subscription.
/// </summary>
public class InstanceActor : ReceiveActor
{
    private readonly string _instanceUniqueName;
    private readonly SiteStorageService _storage;
    private readonly ScriptCompilationService _compilationService;
    private readonly SharedScriptLibrary _sharedScriptLibrary;
    private readonly SiteStreamManager? _streamManager;
    private readonly SiteRuntimeOptions _options;
    private readonly ILogger _logger;
    private readonly ISiteHealthCollector? _healthCollector;
    private readonly IServiceProvider? _serviceProvider;
    private readonly Dictionary<string, object?> _attributes = new();
    private readonly Dictionary<string, string> _attributeQualities = new();
    private readonly Dictionary<string, DateTimeOffset> _attributeTimestamps = new();
    private readonly Dictionary<string, AlarmState> _alarmStates = new();
    private readonly Dictionary<string, DateTimeOffset> _alarmTimestamps = new();
    private readonly Dictionary<string, int> _alarmPriorities = new();
    private readonly Dictionary<string, IActorRef> _scriptActors = new();
    private readonly Dictionary<string, IActorRef> _alarmActors = new();
    private readonly Dictionary<string, IActorRef> _nativeAlarmActors = new();

    /// <summary>
    /// Per-attribute child routing (P2): monitoredAttributeName → the script/alarm actors
    /// that react only to that attribute (value-change / conditional scripts;
    /// HiLo/ValueMatch/RangeViolation/RateOfChange alarms). Built once in
    /// <see cref="CreateChildActors"/> from each child's trigger config and consulted by
    /// <see cref="PublishAndNotifyChildren"/> so an attribute change reaches only the
    /// children interested in it instead of every child. The child's own trigger gate
    /// remains authoritative — this routing is a fan-out optimization, not the correctness
    /// check.
    /// </summary>
    private readonly Dictionary<string, List<IActorRef>> _childrenByMonitoredAttribute = new();

    /// <summary>
    /// Children that must hear EVERY attribute change (P2): Expression-triggered scripts and
    /// alarms (they keep a full attribute snapshot), plus any child whose trigger config was
    /// unparseable (fail-open). Complements <see cref="_childrenByMonitoredAttribute"/>.
    /// </summary>
    private readonly List<IActorRef> _allChangeChildren = new();
    /// <summary>
    /// Native alarm kind per source-binding canonical name (the same value handed
    /// to each <see cref="NativeAlarmActor"/>'s <c>_nativeKind</c>). Used to stamp
    /// the cosmetic kind badge on placeholder rows for quiet bindings in the
    /// DebugView snapshot.
    /// </summary>
    private readonly Dictionary<string, AlarmKind> _nativeAlarmKinds = new();
    /// <summary>
    /// Latest enriched <see cref="AlarmStateChanged"/> per alarm name (computed and
    /// native), so the DebugView snapshot carries the unified condition + native
    /// metadata rather than a bare State/Priority projection.
    /// </summary>
    private readonly Dictionary<string, AlarmStateChanged> _latestAlarmEvents = new();
    private FlattenedConfiguration? _configuration;

    // Resolved attributes indexed by canonical name. The TagValueUpdate
    // ingest path is the highest-frequency message this actor handles, so the
    // attribute lookup must be O(1) rather than a linear scan of
    // _configuration.Attributes. Built once in the constructor from the
    // deserialized configuration (last-wins on duplicate canonical names,
    // mirroring the rest of the actor's by-name dictionaries). Each entry
    // additionally carries its DataType/ElementDataType parsed ONCE at
    // config-apply time (perf remediation, arch-review WP1.5) so the hot
    // TagValueUpdate path never re-Enum.TryParse's a string per update.
    private readonly Dictionary<string, AttributeIndexEntry> _resolvedAttributeByName = new();

    // Static-override write coalescing (perf remediation, arch-review WP1.5):
    // mirrors NativeAlarmActor's MarkDirtyUpsert/FlushDirtyUpserts (P4) shape.
    // A burst of rapid SetAttribute calls on the same or different attributes
    // collapses into one batched SQLite transaction on a short single-shot
    // timer instead of a write per call. Keyed by attribute name — only the
    // LATEST value per attribute survives a coalescing window, which matches
    // the "in-memory state is authoritative" fire-and-forget persistence model
    // already documented on the single-write path this replaces.
    private readonly Dictionary<string, string> _dirtyStaticOverrides = new();
    private ICancelable? _staticOverrideFlushTimer;
    private readonly TimeSpan _staticOverrideFlushInterval;

    /// <summary>
    /// Cached string forms of <see cref="QualityCode"/> (perf remediation, arch-review
    /// WP1.5) — the TagValueUpdate ingest path calls this once or twice per update, so a
    /// static lookup replaces repeated <see cref="Enum.ToString()"/> reflection.
    /// </summary>
    private static readonly Dictionary<QualityCode, string> QualityStrings = new()
    {
        [QualityCode.Good] = "Good",
        [QualityCode.Bad] = "Bad",
        [QualityCode.Uncertain] = "Uncertain",
    };

    private static string QualityToString(QualityCode quality) =>
        QualityStrings.TryGetValue(quality, out var s) ? s : quality.ToString();

    /// <summary>
    /// O(1) attribute-resolution index entry (perf remediation, arch-review WP1.5): wraps a
    /// <see cref="ResolvedAttribute"/> together with its <see cref="DataType"/> /
    /// element <see cref="DataType"/> / IsList classification, parsed ONCE when the entry
    /// is built (constructor) instead of re-parsed via <see cref="Enum.TryParse{DataType}"/>
    /// on every access.
    /// </summary>
    private sealed record AttributeIndexEntry(
        ResolvedAttribute Attribute,
        DataType DataType,
        DataType? ElementDataType,
        bool IsList);

    // WaitForAttribute (spec §4.2): one-shot waiter registry keyed by the
    // request CorrelationId. Each entry holds the watched attribute name, the
    // match test (decoded target equality OR a site-local predicate), the
    // original Sender to reply to, and the scheduled-timeout handle so a match
    // can cancel it. Single-threaded actor access — no locking needed.
    private readonly Dictionary<string, PendingWait> _attributeWaiters = new();

    // WaitForAttribute: defensive per-instance cap so a script leaking waiters
    // in a loop cannot grow the registry without bound. Exceeding it refuses the
    // wait with an error reply rather than registering.
    private const int MaxAttributeWaiters = 100;

    // DCL manager actor reference for subscribing to tag values
    private readonly IActorRef? _dclManager;
    // Maps each tag path to every attribute canonical name that references it.
    // A tag path can back more than one attribute (e.g. two composed modules
    // whose members reference the same PLC node), so a tag value update must
    // fan out to all of them — not just the last one registered.
    private readonly Dictionary<string, List<string>> _tagPathToAttributes = new();

    // Tag-subscription retry state (S4/UA6): tag paths grouped by connection name
    // (built once in SubscribeToDcl), the in-flight correlation-id → connection
    // map, and a per-connection retry timer. Arming the timer on *send* closes both
    // the failed-response and the lost-response gaps with one mechanism; the timer
    // is cancelled when a Success reply arrives.
    private readonly Dictionary<string, List<string>> _tagsByConnection = new();
    private readonly Dictionary<string, string> _pendingTagSubscribes = new();
    private readonly Dictionary<string, ICancelable> _tagSubscribeRetryTimers = new();

    /// <summary>
    /// Initializes the instance actor with its configuration and dependencies.
    /// </summary>
    /// <param name="instanceUniqueName">System-wide unique name identifying this instance.</param>
    /// <param name="configJson">JSON-serialized flattened configuration for this instance.</param>
    /// <param name="storage">Site storage service for loading and persisting static overrides.</param>
    /// <param name="compilationService">Service used to compile instance scripts.</param>
    /// <param name="sharedScriptLibrary">Library of shared scripts available to instance scripts.</param>
    /// <param name="streamManager">Optional site stream manager for publishing attribute/alarm changes.</param>
    /// <param name="options">Site runtime configuration options.</param>
    /// <param name="logger">Logger for this actor.</param>
    /// <param name="dclManager">Optional Data Connection Layer manager actor reference.</param>
    /// <param name="healthCollector">Optional health collector for reporting metrics.</param>
    /// <param name="serviceProvider">Optional DI service provider for script execution services.</param>
    public InstanceActor(
        string instanceUniqueName,
        string configJson,
        SiteStorageService storage,
        ScriptCompilationService compilationService,
        SharedScriptLibrary sharedScriptLibrary,
        SiteStreamManager? streamManager,
        SiteRuntimeOptions options,
        ILogger logger,
        IActorRef? dclManager = null,
        ISiteHealthCollector? healthCollector = null,
        IServiceProvider? serviceProvider = null,
        TimeSpan? staticOverrideFlushInterval = null)
    {
        _instanceUniqueName = instanceUniqueName;
        _storage = storage;
        _compilationService = compilationService;
        _sharedScriptLibrary = sharedScriptLibrary;
        _streamManager = streamManager;
        _options = options;
        _logger = logger;
        _dclManager = dclManager;
        _healthCollector = healthCollector;
        _serviceProvider = serviceProvider;
        // Defaults to 100 ms, mirroring NativeAlarmActor's persistFlushInterval (P4).
        _staticOverrideFlushInterval = staticOverrideFlushInterval ?? TimeSpan.FromMilliseconds(100);

        // Deserialize the flattened configuration
        _configuration = JsonSerializer.Deserialize<FlattenedConfiguration>(configJson);

        // Load default attribute values from the flattened configuration
        // Data-sourced attributes start with Uncertain quality until the first DCL value arrives.
        // Static attributes start with Good quality.
        if (_configuration != null)
        {
            foreach (var attr in _configuration.Attributes)
            {
                // Parse DataType/ElementDataType ONCE here (config-apply time) and index
                // the wrapping entry for O(1) lookup on the hot TagValueUpdate ingest path
                // (last-wins on duplicate names) — perf remediation, arch-review WP1.5.
                var dataType = ParseDataType(attr.DataType);
                var elementDataType = ParseElementDataType(attr.ElementDataType);
                var isList = dataType == DataType.List;
                _resolvedAttributeByName[attr.CanonicalName] =
                    new AttributeIndexEntry(attr, dataType, elementDataType, isList);

                // A STATIC List attribute's default is the canonical JSON
                // array string. Decode it to a typed List<T> for in-memory reads
                // so scripts see a real collection. Scalars store their raw
                // string unchanged. A malformed List default decodes to null and
                // is marked Bad quality rather than crashing the actor.
                if (isList)
                {
                    var decoded = DecodeAttributeValue(dataType, elementDataType, attr.Value, attr.CanonicalName);
                    _attributes[attr.CanonicalName] = decoded;
                    _attributeQualities[attr.CanonicalName] =
                        decoded is null && !string.IsNullOrEmpty(attr.Value) ? "Bad"
                        : string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
                }
                else
                {
                    _attributes[attr.CanonicalName] = attr.Value;
                    _attributeQualities[attr.CanonicalName] =
                        string.IsNullOrEmpty(attr.DataSourceReference) ? "Good" : "Uncertain";
                }
            }
        }

        // Handle attribute queries (Tell pattern -- sender gets response)
        Receive<GetAttributeRequest>(HandleGetAttribute);

        // Handle static attribute writes
        Receive<SetStaticAttributeCommand>(HandleSetStaticAttribute);

        // The disable/enable lifecycle is owned entirely by the
        // Deployment Manager — DeploymentManagerActor.HandleDisable/HandleEnable
        // stop or re-create the Instance Actor directly and reply to the caller.
        // DisableInstanceCommand / EnableInstanceCommand are never routed to the
        // Instance Actor, so no handlers are registered here. (The previous no-op
        // handlers were dead code that implied a non-existent instance-side
        // acknowledgement contract.)

        // Handle script call requests — route to appropriate Script Actor (Ask pattern)
        Receive<ScriptCallRequest>(HandleScriptCallRequest);

        // Handle attribute value changes from DCL (Tell pattern)
        Receive<AttributeValueChanged>(HandleAttributeValueChanged);

        // WaitForAttribute (spec §4.2): event-driven "wait for value" waiter
        // registration + its scheduled-timeout self-message. Both flow only
        // site-locally (the predicate variant carries a non-serializable delegate).
        Receive<WaitForAttributeRequest>(HandleWaitForAttribute);
        Receive<WaitForAttributeTimeout>(HandleWaitForAttributeTimeout);

        // Batch write + (event-driven) wait: resolves a set of data-sourced
        // attributes to one DCL connection and forwards a single WriteTagBatchRequest.
        // Backs the script-facing Attributes.WriteBatchAndWaitAsync helper; the wait
        // half is the WaitForAttribute waiter above.
        Receive<WriteAttributeBatchRequest>(HandleWriteAttributeBatch);

        // Handle tag value updates from DCL — convert to AttributeValueChanged
        Receive<TagValueUpdate>(HandleTagValueUpdate);
        Receive<SubscribeTagsResponse>(HandleSubscribeTagsResponse); // S4/UA6: real retry-aware handler
        Receive<RetryTagSubscribe>(msg => SendTagSubscribe(msg.ConnectionName));
        Receive<ConnectionQualityChanged>(HandleConnectionQualityChanged);

        // Handle alarm state changes from Alarm Actors (Tell pattern)
        Receive<AlarmStateChanged>(HandleAlarmStateChanged);

        // A NativeAlarmActor tells us when one of its native
        // conditions has left the mirror for good (snapshot-swap removal, retention
        // drop, or cap eviction) so we can evict the stale _latestAlarmEvents key
        // and not leak per-instance memory / bloat DebugView snapshots.
        Receive<NativeAlarmDropped>(HandleNativeAlarmDropped);

        // Debug view subscribe/unsubscribe (Ask pattern for snapshot)
        Receive<SubscribeDebugViewRequest>(HandleSubscribeDebugView);
        Receive<UnsubscribeDebugViewRequest>(HandleUnsubscribeDebugView);

        // Debug snapshot (one-shot, no subscription)
        Receive<DebugSnapshotRequest>(HandleDebugSnapshot);

        // Script-facing alarm snapshot backing Alarms.CurrentAsync()
        Receive<GetAlarmSnapshotRequest>(HandleGetAlarmSnapshot);

        // Handle internal messages
        Receive<LoadOverridesResult>(HandleOverridesLoaded);

        // Coalesced flush of buffered static-override writes (P4 pattern, see
        // NativeAlarmActor.FlushDirtyUpserts).
        Receive<FlushStaticOverrides>(_ => FlushDirtyStaticOverrides());
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        base.PreStart();
        _logger.LogInformation("InstanceActor started for {Instance}", _instanceUniqueName);

        // Operational `instance_lifecycle` event — instance started.
        // An instance starts on deploy, on enable (DeploymentManager re-creates
        // the actor), and on failover/restart; this single point covers them all.
        LogLifecycleEvent($"Instance {_instanceUniqueName} started");

        // Asynchronously load static overrides from SQLite and pipe to self
        var self = Self;
        _storage.GetStaticOverridesAsync(_instanceUniqueName).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
                return new LoadOverridesResult(t.Result, null);
            return new LoadOverridesResult(new Dictionary<string, string>(), t.Exception?.GetBaseException().Message);
        }).PipeTo(self);

        // Create child Script Actors and Alarm Actors from configuration
        CreateChildActors();

        // Subscribe to DCL for data-sourced attributes
        SubscribeToDcl();

        // Init succeeded — tell the Deployment Manager so it can confirm the
        // deployment (S6). If any step above throws, PreStart aborts before this
        // line and the actor is stopped with ActorInitializationException instead,
        // so the DM never sees a readiness signal for an instance that failed to init.
        Context.Parent.Tell(new InstanceActorInitialized(_instanceUniqueName));
    }

    /// <inheritdoc />
    protected override void PostStop()
    {
        // Cancel any armed tag-subscribe retry timers so they cannot fire after stop.
        foreach (var timer in _tagSubscribeRetryTimers.Values)
            timer.Cancel();
        _tagSubscribeRetryTimers.Clear();

        // Best-effort final flush of any coalesced static-override writes so a graceful
        // stop does not lose the last <= flush-interval of writes (P4 pattern, mirrors
        // NativeAlarmActor.PostStop). Fire-and-forget — the in-memory attribute value
        // (already applied) remains authoritative even if this final persist is lost.
        _staticOverrideFlushTimer?.Cancel();
        FlushDirtyStaticOverrides();

        // Operational `instance_lifecycle` event — instance stopped. An
        // instance stops on disable, delete, redeployment, and graceful shutdown;
        // this single point covers them all.
        LogLifecycleEvent($"Instance {_instanceUniqueName} stopped");
        base.PostStop();
    }

    /// <summary>
    /// Fire-and-forget an <c>instance_lifecycle</c> operational event to the
    /// optional <see cref="ISiteEventLogger"/>. Resolved optionally and never
    /// awaited so a logging failure cannot affect the instance lifecycle
    /// (matching the established ScriptActor / script-run pattern).
    /// </summary>
    private void LogLifecycleEvent(string message)
    {
        _ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
            "instance_lifecycle", "Info", _instanceUniqueName,
            $"InstanceActor:{_instanceUniqueName}", message);
    }

    /// <inheritdoc />
    protected override SupervisorStrategy SupervisorStrategy()
    {
        return new OneForOneStrategy(
            maxNrOfRetries: -1,
            withinTimeRange: TimeSpan.FromMinutes(1),
            decider: Decider.From(ex =>
            {
                _logger.LogWarning(ex,
                    "Child actor on instance {Instance} threw exception, resuming",
                    _instanceUniqueName);
                return Directive.Resume;
            }));
    }

    /// <summary>
    /// Returns the current attribute value. Uses Tell pattern; sender gets the response.
    /// </summary>
    private void HandleGetAttribute(GetAttributeRequest request)
    {
        var found = _attributes.TryGetValue(request.AttributeName, out var value);
        _attributeQualities.TryGetValue(request.AttributeName, out var quality);
        Sender.Tell(new GetAttributeResponse(
            request.CorrelationId,
            _instanceUniqueName,
            request.AttributeName,
            value,
            found,
            quality ?? "Good",
            DateTimeOffset.UtcNow));
    }

    /// <summary>
    /// Handles an attribute write (<c>Instance.SetAttribute</c> / Inbound API).
    /// State mutation serialized through this actor's mailbox.
    ///
    /// The write is routed by the attribute's data binding:
    ///   * Data-sourced attribute  → forwards a <see cref="WriteTagRequest"/> to the
    ///     DCL, which writes the physical device. The in-memory value is NOT
    ///     optimistically updated and NO static override is persisted — the
    ///     confirmed device value arrives later via the subscription. Success or
    ///     failure of the device write is returned to the caller.
    ///   * Static attribute → updates the in-memory value and persists the override
    ///     to SQLite.
    ///
    /// Either way the caller receives a <see cref="SetStaticAttributeResponse"/>.
    /// </summary>
    private void HandleSetStaticAttribute(SetStaticAttributeCommand command)
    {
        // O(1) lookup via the existing hot-path index instead of a linear scan over
        // _configuration.Attributes (perf remediation, arch-review WP1.5).
        _resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved);

        // Reject writes targeting an attribute that does not exist
        // on the deployed instance. Without this check, an inbound API
        // SetAttribute("notARealAttr", ...) would pollute the in-memory
        // _attributes dictionary, publish a synthetic AttributeValueChanged to
        // debug-view subscribers, and persist a durable static-override row that
        // resurrects on every restart. The override row is also outside the
        // ClearStaticOverridesAsync window for unknown names. Refuse the write
        // and let the caller see the failure, mirroring the script trust model's
        // "scripts can only read/write attributes on their own instance" framing.
        if (resolved == null)
        {
            _logger.LogWarning(
                "SetAttribute rejected — attribute '{Attribute}' is not defined on instance '{Instance}'",
                command.AttributeName, _instanceUniqueName);
            Sender.Tell(new SetStaticAttributeResponse(
                command.CorrelationId,
                _instanceUniqueName,
                command.AttributeName,
                false,
                $"Unknown attribute '{command.AttributeName}'",
                DateTimeOffset.UtcNow));
            return;
        }

        var isDataSourced =
            !string.IsNullOrEmpty(resolved.Attribute.DataSourceReference)
            && !string.IsNullOrEmpty(resolved.Attribute.BoundDataConnectionName);

        if (isDataSourced)
        {
            HandleSetDataAttribute(command, resolved);
            return;
        }

        HandleSetStaticAttributeCore(command);
    }

    /// <summary>
    /// Static attribute write: updates in-memory state, publishes the change,
    /// persists the override to SQLite, and replies with success.
    /// </summary>
    private void HandleSetStaticAttributeCore(SetStaticAttributeCommand command)
    {
        // command.Value is the canonical form — a plain string for scalars,
        // a JSON array string for List attributes. For a List attribute we store
        // the DECODED typed list in memory (so scripts read a real collection) but
        // persist + publish the canonical JSON string UNCHANGED below. Scalars
        // store the string verbatim. (HandleSetStaticAttribute already rejected
        // unknown attributes, so resolved is non-null here, but guard defensively.)
        if (_resolvedAttributeByName.TryGetValue(command.AttributeName, out var resolved)
            && resolved.IsList)
        {
            // The script path pre-encodes valid canonical JSON via ScopeAccessors,
            // but the Inbound API / direct-command path can submit an arbitrary
            // command.Value. A non-empty value that fails to decode (malformed JSON,
            // bad element, missing element type) is poison: storing it would null the
            // in-memory value yet publish "Good" quality and durably persist the bad
            // JSON (which then loads as Bad next restart). Reject such writes outright.
            // Note: DecodeAttributeValue returns null for BOTH a null/empty input
            // (valid — clearing) AND a malformed non-empty input (invalid). Only the
            // latter is rejected, hence the explicit IsNullOrWhiteSpace guard. An empty
            // list "[]" decodes to a non-null empty List, so it passes through.
            var decoded = DecodeAttributeValue(
                resolved.DataType, resolved.ElementDataType, command.Value, command.AttributeName);
            if (!string.IsNullOrWhiteSpace(command.Value) && decoded == null)
            {
                _logger.LogWarning(
                    "SetAttribute rejected — value for List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
                    command.AttributeName, _instanceUniqueName);
                Sender.Tell(new SetStaticAttributeResponse(
                    command.CorrelationId,
                    _instanceUniqueName,
                    command.AttributeName,
                    false,
                    $"Invalid list value for attribute '{command.AttributeName}'",
                    DateTimeOffset.UtcNow));
                return;
            }

            _attributes[command.AttributeName] = decoded;
        }
        else
        {
            _attributes[command.AttributeName] = command.Value;
        }

        // Publish attribute change to stream and notify children
        var changed = new AttributeValueChanged(
            _instanceUniqueName,
            command.AttributeName,
            command.AttributeName,
            command.Value,
            "Good",
            DateTimeOffset.UtcNow,
            // (ParentExecutionId tag-cascade): carry the writing
            // execution's id onto the change so a child Alarm Actor this write
            // trips can chain its on-trigger script run under the writer. Null for
            // writes with no audited origin (central Test Run, deployment tooling).
            SourceExecutionId: command.SourceExecutionId);

        PublishAndNotifyChildren(changed);

        // Coalesce the persist: record the latest value and arm the short batched-flush
        // timer (P4 pattern) instead of firing a SQLite write per call — the actor's
        // in-memory state (already updated above) remains the source of truth regardless
        // of when the buffered write lands.
        MarkDirtyStaticOverride(command.AttributeName, command.Value);

        Sender.Tell(new SetStaticAttributeResponse(
            command.CorrelationId, _instanceUniqueName, command.AttributeName,
            true, null, DateTimeOffset.UtcNow));
    }

    /// <summary>
    /// Records the latest value per attribute name and arms the coalesced flush timer
    /// (perf remediation, arch-review WP1.5) — mirrors NativeAlarmActor's
    /// MarkDirtyUpsert/FlushDirtyUpserts (P4) shape. A burst of rapid static writes
    /// collapses into one batched SQLite transaction instead of a write per call.
    /// </summary>
    private void MarkDirtyStaticOverride(string attributeName, string value)
    {
        _dirtyStaticOverrides[attributeName] = value;
        if (_staticOverrideFlushTimer == null)
        {
            _staticOverrideFlushTimer = Context.System.Scheduler.ScheduleTellOnceCancelable(
                _staticOverrideFlushInterval, Self, FlushStaticOverrides.Instance, Self);
        }
    }

    /// <summary>
    /// Persists all coalesced static-override writes in one batched transaction (P4),
    /// then disarms the flush timer. Fire-and-forget with OnlyOnFaulted logging — a
    /// failed write never blocks the actor; the in-memory attribute value (already
    /// applied) remains authoritative.
    /// </summary>
    private void FlushDirtyStaticOverrides()
    {
        _staticOverrideFlushTimer = null;
        if (_dirtyStaticOverrides.Count == 0)
            return;

        var batch = _dirtyStaticOverrides
            .Select(kv => (AttributeName: kv.Key, Value: kv.Value))
            .ToList();
        _dirtyStaticOverrides.Clear();

        var instanceName = _instanceUniqueName;
        var logger = _logger;
        _storage.SetStaticOverridesAsync(instanceName, batch)
            .ContinueWith(t => logger.LogWarning(
                t.Exception?.GetBaseException(),
                "Failed to persist {Count} static overrides for {Instance}; in-memory state is authoritative",
                batch.Count, instanceName),
                TaskContinuationOptions.OnlyOnFaulted);
    }

    /// <summary>
    /// Data-sourced attribute write: forwards a write request to the DCL and pipes
    /// the device write result back to the caller. The in-memory value is left
    /// untouched (it is refreshed by the subscription when the device confirms);
    /// no static override is persisted for a data-sourced attribute.
    /// </summary>
    private void HandleSetDataAttribute(SetStaticAttributeCommand command, AttributeIndexEntry resolved)
    {
        var caller = Sender;
        var correlationId = command.CorrelationId;
        var attributeName = command.AttributeName;
        var instanceName = _instanceUniqueName;

        if (_dclManager == null)
        {
            _logger.LogWarning(
                "SetAttribute on data-sourced attribute {Instance}.{Attribute} cannot be routed — no DCL manager configured",
                instanceName, attributeName);
            caller.Tell(new SetStaticAttributeResponse(
                correlationId, instanceName, attributeName, false,
                "Data Connection Layer not available for write.", DateTimeOffset.UtcNow));
            return;
        }

        // MV: for a data-sourced List attribute the incoming command.Value is
        // the canonical JSON array string (ScopeAccessors encodes the script's
        // List<T> for transport/storage). Writing that string straight to the DCL
        // would push a String scalar to an array node. Decode it back to a typed
        // List<T> so the DCL/Variant write produces a real array. A non-empty value
        // that fails to decode (malformed JSON / bad element) is poison — reject the
        // write rather than forward garbage to the device (mirrors the static-path
        // rejection in HandleSetStaticAttributeCore). Scalars are unchanged.
        object? writeValue = command.Value;
        if (resolved.IsList && !string.IsNullOrWhiteSpace(command.Value))
        {
            var decoded = DecodeAttributeValue(
                resolved.DataType, resolved.ElementDataType, command.Value, attributeName);
            if (decoded == null)
            {
                _logger.LogWarning(
                    "SetAttribute rejected — value for data-sourced List attribute '{Attribute}' on instance '{Instance}' is not a valid list",
                    attributeName, instanceName);
                caller.Tell(new SetStaticAttributeResponse(
                    correlationId, instanceName, attributeName, false,
                    $"Invalid list value for attribute '{attributeName}'", DateTimeOffset.UtcNow));
                return;
            }

            writeValue = decoded;
        }

        var writeRequest = new WriteTagRequest(
            correlationId,
            resolved.Attribute.BoundDataConnectionName!,
            resolved.Attribute.DataSourceReference!,
            writeValue,
            DateTimeOffset.UtcNow);

        // Ask the DCL and pipe the result back to the original caller. The DCL
        // returns the failure synchronously so the script can handle it.
        _dclManager.Ask<WriteTagResponse>(writeRequest, TimeSpan.FromSeconds(30))
            .ContinueWith(t =>
            {
                if (t.IsCompletedSuccessfully)
                    return new SetStaticAttributeResponse(
                        correlationId, instanceName, attributeName,
                        t.Result.Success, t.Result.ErrorMessage, DateTimeOffset.UtcNow);

                return new SetStaticAttributeResponse(
                    correlationId, instanceName, attributeName, false,
                    t.Exception?.GetBaseException().Message ?? "DCL write timed out",
                    DateTimeOffset.UtcNow);
            }).PipeTo(caller);
    }

    /// <summary>
    /// Batch write: resolves a SET of data-sourced attributes (and an optional trigger
    /// attribute) to their device bindings, enforces a single data connection across the
    /// whole batch, decodes List values (same rule as <see cref="HandleSetDataAttribute"/>),
    /// and forwards ONE <see cref="WriteTagBatchRequest"/> to the DCL — replacing N
    /// sequential per-attribute writes with a single gateway round-trip. The write outcome
    /// is returned synchronously to the caller; the EVENT-DRIVEN wait for the response
    /// attribute is performed separately by the caller via the existing WaitForAttribute
    /// waiter (this handler does not wait). Resolution mirrors the data-sourced
    /// SetAttribute path: an attribute is data-sourced only when it resolves AND has both a
    /// <see cref="ResolvedAttribute.DataSourceReference"/> and a
    /// <see cref="ResolvedAttribute.BoundDataConnectionName"/>.
    /// </summary>
    private void HandleWriteAttributeBatch(WriteAttributeBatchRequest request)
    {
        var caller = Sender;
        var cid = request.CorrelationId;

        if (_dclManager == null)
        {
            caller.Tell(new WriteAttributeBatchResponse(
                cid, false, "Data Connection Layer not available for write."));
            return;
        }

        var values = new Dictionary<string, object?>();
        string? connName = null;

        foreach (var kv in request.AttributeEncodedValues)
        {
            if (!_resolvedAttributeByName.TryGetValue(kv.Key, out var resolved)
                || string.IsNullOrEmpty(resolved.Attribute.DataSourceReference)
                || string.IsNullOrEmpty(resolved.Attribute.BoundDataConnectionName))
            {
                caller.Tell(new WriteAttributeBatchResponse(
                    cid, false, $"Attribute '{kv.Key}' is not a data-sourced attribute."));
                return;
            }

            if (connName == null)
                connName = resolved.Attribute.BoundDataConnectionName;
            else if (!string.Equals(connName, resolved.Attribute.BoundDataConnectionName, StringComparison.Ordinal))
            {
                caller.Tell(new WriteAttributeBatchResponse(
                    cid, false, "Batch write spans multiple data connections; not supported."));
                return;
            }

            object? wv = kv.Value;
            // MV: a data-sourced List attribute's encoded value is the canonical JSON
            // array string — decode it to a typed List<T> so the DCL/Variant write
            // produces a real array (same poison-rejection rule as HandleSetDataAttribute).
            if (resolved.IsList && !string.IsNullOrWhiteSpace(kv.Value))
            {
                var decoded = DecodeAttributeValue(
                    resolved.DataType, resolved.ElementDataType, kv.Value, kv.Key);
                if (decoded == null)
                {
                    caller.Tell(new WriteAttributeBatchResponse(
                        cid, false, $"Invalid list value for attribute '{kv.Key}'"));
                    return;
                }
                wv = decoded;
            }

            values[resolved.Attribute.DataSourceReference!] = wv;
        }

        string? triggerPath = null;
        object? triggerVal = null;
        if (!string.IsNullOrEmpty(request.TriggerAttribute))
        {
            if (!_resolvedAttributeByName.TryGetValue(request.TriggerAttribute, out var tr)
                || string.IsNullOrEmpty(tr.Attribute.DataSourceReference)
                || string.IsNullOrEmpty(tr.Attribute.BoundDataConnectionName))
            {
                caller.Tell(new WriteAttributeBatchResponse(
                    cid, false, $"Trigger attribute '{request.TriggerAttribute}' is not data-sourced."));
                return;
            }

            if (connName != null && !string.Equals(connName, tr.Attribute.BoundDataConnectionName, StringComparison.Ordinal))
            {
                caller.Tell(new WriteAttributeBatchResponse(
                    cid, false, "Trigger attribute is on a different data connection."));
                return;
            }

            connName ??= tr.Attribute.BoundDataConnectionName;
            triggerPath = tr.Attribute.DataSourceReference;
            triggerVal = request.TriggerEncodedValue;
        }

        if (connName == null)
        {
            caller.Tell(new WriteAttributeBatchResponse(cid, false, "No attributes to write."));
            return;
        }

        var dclReq = new WriteTagBatchRequest(
            cid, connName!, values, triggerPath, triggerVal, DateTimeOffset.UtcNow);

        // Ask the DCL and pipe the batch outcome back to the original caller. The DCL
        // bounds its own write with WriteTimeout; this Ask is bounded a bit longer so a
        // device timeout is surfaced as a DCL failure rather than an Ask timeout.
        _dclManager.Ask<WriteTagBatchResponse>(dclReq, TimeSpan.FromSeconds(30))
            .ContinueWith(t => t.IsCompletedSuccessfully
                ? new WriteAttributeBatchResponse(cid, t.Result.Success, t.Result.ErrorMessage)
                : new WriteAttributeBatchResponse(
                    cid, false, t.Exception?.GetBaseException().Message ?? "DCL batch write timed out"))
            .PipeTo(caller);
    }

    /// <summary>
    /// Routes script call requests to the appropriate Script Actor.
    /// Uses Ask pattern.
    /// </summary>
    private void HandleScriptCallRequest(ScriptCallRequest request)
    {
        if (_scriptActors.TryGetValue(request.ScriptName, out var scriptActor))
        {
            // Forward the request to the Script Actor, preserving the original
            // sender. The whole record is forwarded unchanged, so any
            // ParentExecutionId set by an inbound-API-routed
            // call is carried through to the Script Actor verbatim.
            scriptActor.Forward(request);
        }
        else
        {
            Sender.Tell(new ScriptCallResult(
                request.CorrelationId,
                false,
                null,
                $"Script '{request.ScriptName}' not found on instance '{_instanceUniqueName}'."));
        }
    }

    /// <summary>
    /// Handles attribute value changes from DCL or static writes.
    /// Updates in-memory state, publishes to stream, and notifies children.
    /// </summary>
    private void HandleAttributeValueChanged(AttributeValueChanged changed)
    {
        // State mutation serialized through this actor
        _attributes[changed.AttributeName] = changed.Value;
        _attributeQualities[changed.AttributeName] = changed.Quality;
        _attributeTimestamps[changed.AttributeName] = changed.Timestamp;

        PublishAndNotifyChildren(changed);
    }

    /// <summary>
    /// WaitForAttribute (spec §4.2): registers a one-shot event-driven waiter for
    /// an attribute to reach a value (encoded-equality), satisfy a site-local
    /// predicate, or change at all. The current-value fast-path and the
    /// change-handling in <see cref="HandleAttributeValueChanged"/> both run on
    /// this single-threaded actor, so a value that flips between "read current"
    /// and "register" cannot be missed (spec §5).
    /// </summary>
    private void HandleWaitForAttribute(WaitForAttributeRequest req)
    {
        // Capture the sender immediately — Sender is invalid once we schedule /
        // return and a later message arrives.
        var replyer = Sender;

        // Build the match test: explicit predicate wins; else null encoded target
        // means "any change"; else compare the codec-encoded current value to the
        // encoded target (avoids needing the attribute's DataType to decode).
        Func<object?, bool> test;
        if (req.Predicate is not null)
        {
            test = req.Predicate;
        }
        else if (req.TargetValueEncoded is null)
        {
            test = _ => true;
        }
        else
        {
            var target = req.TargetValueEncoded;
            test = v => string.Equals(
                AttributeValueCodec.Encode(v), target, StringComparison.Ordinal);
        }

        // Fast path: the current value already satisfies the test → reply now.
        // A script-supplied predicate (or the codec-equality lambda) runs on the
        // actor thread; guard it so a throwing predicate cannot crash the actor or
        // leak a never-resolved waiter. On throw: reply non-matched + ErrorMessage
        // and return WITHOUT registering (no timeout scheduled).
        if (_attributes.TryGetValue(req.AttributeName, out var current))
        {
            // Effective quality used for BOTH the §4.2 quality gate and the match
            // reply — the same `?? "Good"` default the reply has always used.
            _attributeQualities.TryGetValue(req.AttributeName, out var fastQuality);
            var effectiveQuality = fastQuality ?? "Good";

            bool fastMatch;
            try
            {
                // §4.2 quality gate ANDed with the value test, both INSIDE the guard:
                // in quality-gated mode a value already at target but at Bad/Uncertain
                // quality is NOT a fast match — it falls through to register + schedule
                // the timeout like any other pending waiter (do NOT fast-reply matched).
                fastMatch =
                    (!req.RequireGoodQuality
                        || string.Equals(effectiveQuality, "Good", StringComparison.Ordinal))
                    && test(current);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex,
                    "WaitForAttribute predicate threw on the fast-path for {Instance}.{Attribute}; refusing the wait",
                    _instanceUniqueName, req.AttributeName);
                replyer.Tell(new WaitForAttributeResponse(
                    req.CorrelationId, Matched: false, null, null, TimedOut: false,
                    ErrorMessage: "Wait predicate threw: " + ex.Message));
                return;
            }

            if (fastMatch)
            {
                replyer.Tell(new WaitForAttributeResponse(
                    req.CorrelationId, Matched: true, current, effectiveQuality, TimedOut: false));
                return;
            }
        }

        // Defensive cap: refuse rather than register if the instance already has
        // too many concurrent waiters (guards against a script leaking waiters).
        if (_attributeWaiters.Count >= MaxAttributeWaiters)
        {
            replyer.Tell(new WaitForAttributeResponse(
                req.CorrelationId, Matched: false, null, null, TimedOut: false,
                ErrorMessage: "Too many concurrent attribute waiters on this instance"));
            return;
        }

        // Register and schedule the self-evicting timeout (NativeAlarmActor idiom).
        var handle = Context.System.Scheduler.ScheduleTellOnceCancelable(
            req.Timeout, Self, new WaitForAttributeTimeout(req.CorrelationId), Self);

        _attributeWaiters[req.CorrelationId] =
            new PendingWait(req.AttributeName, test, replyer, handle, req.RequireGoodQuality);
    }

    /// <summary>
    /// WaitForAttribute (spec §4.2): the scheduled timeout fired for a waiter that
    /// never matched. If still registered (a match would have removed + canceled
    /// it), reply TimedOut and evict it.
    /// </summary>
    private void HandleWaitForAttributeTimeout(WaitForAttributeTimeout msg)
    {
        if (_attributeWaiters.Remove(msg.CorrelationId, out var pending))
        {
            pending.Replyer.Tell(new WaitForAttributeResponse(
                msg.CorrelationId, Matched: false, null, null, TimedOut: true));
        }
    }

    /// <summary>
    /// Handles tag value updates from DCL. Maps the tag path back to the attribute
    /// canonical name and converts to an AttributeValueChanged for unified processing.
    /// </summary>
    private void HandleTagValueUpdate(TagValueUpdate update)
    {
        if (!_tagPathToAttributes.TryGetValue(update.TagPath, out var attrNames))
            return;

        // One tag path may back several attributes — update every one of them.
        // Each attribute is coerced according to its own declared data type, so
        // we resolve and convert per attribute rather than once for the tag.
        foreach (var attrName in attrNames)
        {
            // O(1) lookup off the hot ingest path (was a linear FirstOrDefault).
            _resolvedAttributeByName.TryGetValue(attrName, out var resolved);

            // A List-typed attribute coerces the incoming OPC UA array
            // (a CLR array/IEnumerable from the SDK) into a typed List<T>. On an
            // element-type mismatch we set the attribute's quality to Bad, log a
            // warning, and skip storing a value rather than crashing the actor.
            if (resolved != null && resolved.IsList)
            {
                if (TryCoerceListValue(resolved.ElementDataType, update.Value, out var typedList))
                {
                    HandleAttributeValueChanged(new AttributeValueChanged(
                        _instanceUniqueName, update.TagPath, attrName,
                        typedList, QualityToString(update.Quality), update.Timestamp));
                }
                else
                {
                    _logger.LogWarning(
                        "List attribute {Instance}.{Attribute} received a value that could not be coerced to List<{Element}>; marking quality Bad",
                        _instanceUniqueName, attrName, resolved.Attribute.ElementDataType);
                    _attributeQualities[attrName] = "Bad";
                    _attributeTimestamps[attrName] = update.Timestamp;
                    var currentValue = _attributes.GetValueOrDefault(attrName);
                    // WaitForAttribute (spec §4.2): quality-only republish — the
                    // stored value is UNCHANGED (we publish the OLD currentValue, only
                    // the quality flips to Bad). Do NOT evaluate waiters, or an
                    // "any-change" / unchanged-value-equality waiter would fire on a
                    // non-change.
                    PublishAndNotifyChildren(new AttributeValueChanged(
                        _instanceUniqueName, update.TagPath, attrName,
                        currentValue, "Bad", update.Timestamp), evaluateWaiters: false);
                }
                continue;
            }

            // Scalars and non-List attributes keep the historical behaviour:
            // array values are normalized to JSON strings so they survive Akka
            // serialization; scalars pass through unchanged.
            var value = update.Value is Array
                ? System.Text.Json.JsonSerializer.Serialize(update.Value, update.Value.GetType())
                : update.Value;

            HandleAttributeValueChanged(new AttributeValueChanged(
                _instanceUniqueName, update.TagPath, attrName,
                value, QualityToString(update.Quality), update.Timestamp));
        }
    }

    /// <summary>Parses a <see cref="ResolvedAttribute.DataType"/> string, defaulting to
    /// <see cref="DataType.String"/> on an unrecognized value (never throws).</summary>
    private static DataType ParseDataType(string dataType) =>
        Enum.TryParse<DataType>(dataType, ignoreCase: true, out var dt) ? dt : DataType.String;

    /// <summary>Parses a <see cref="ResolvedAttribute.ElementDataType"/> string, returning
    /// <see langword="null"/> when absent or unrecognized (never throws).</summary>
    private static DataType? ParseElementDataType(string? elementDataType) =>
        string.IsNullOrEmpty(elementDataType)
            ? null
            : (Enum.TryParse<DataType>(elementDataType, ignoreCase: true, out var et) ? et : (DataType?)null);

    /// <summary>
    /// Decodes a STATIC (authored / overridden) attribute's canonical value
    /// for in-memory storage. List attributes carry a canonical JSON array string
    /// (config default or persisted override) which is decoded via
    /// <see cref="AttributeValueCodec.Decode"/> into a typed <c>List&lt;T&gt;</c>
    /// so scripts read a real collection; scalars pass through unchanged. This is
    /// the authored counterpart to <see cref="TryCoerceListValue"/> (which
    /// coerces live OPC UA CLR arrays). An undecodable List value (malformed JSON,
    /// bad element, missing element type) degrades to <see langword="null"/> + a
    /// warning — the caller marks the attribute Bad quality. NEVER throws into the
    /// actor.
    ///
    /// Takes the already-parsed <paramref name="dataType"/>/<paramref name="elementType"/>
    /// (perf remediation, arch-review WP1.5) rather than re-parsing from the attribute's
    /// raw DataType strings — every call site now resolves these once via
    /// <see cref="AttributeIndexEntry"/> at config-apply time.
    /// </summary>
    private object? DecodeAttributeValue(
        DataType dataType, DataType? elementType, string? raw, string attributeCanonicalName)
    {
        try
        {
            return AttributeValueCodec.Decode(raw, dataType, elementType);
        }
        catch (FormatException ex)
        {
            _logger.LogWarning(ex,
                "Attribute '{Attr}' on '{Instance}' has an undecodable List value; marking Bad quality",
                attributeCanonicalName, _instanceUniqueName);
            return null; // caller marks quality Bad
        }
    }

    /// <summary>
    /// Coerces an incoming data-sourced value (an OPC UA array / IEnumerable)
    /// into a typed <c>List&lt;elementClrType&gt;</c> matching the attribute's element
    /// type. Each element is converted with invariant culture (round-trip parse for
    /// DateTime). Returns <see langword="false"/> on a missing/invalid element type, a
    /// non-enumerable value, or any element that cannot be coerced — the caller then
    /// marks the attribute quality Bad. Never throws.
    ///
    /// Takes the already-parsed <paramref name="elementType"/> (perf remediation,
    /// arch-review WP1.5) — see <see cref="DecodeAttributeValue"/>.
    /// </summary>
    private bool TryCoerceListValue(DataType? elementType, object? incoming, out object? typedList)
    {
        typedList = null;

        if (elementType is not { } et || !AttributeValueCodec.IsValidElementType(et))
            return false;

        if (incoming is not System.Collections.IEnumerable enumerable || incoming is string)
            return false;

        try
        {
            // Coerce INSIDE the try: although the six valid element types cannot
            // throw on construction today, keeping the (shared) codec coercion
            // inside the guarded block means any future change that introduces a
            // throw is caught and turned into a Bad-quality result rather than
            // escaping into the actor and tripping supervision.
            typedList = AttributeValueCodec.CoerceEnumerable(enumerable, et);
            return true;
        }
        catch (Exception ex)
        {
            // Any coercion / construction failure → Bad quality, never a crash.
            _logger.LogWarning(ex,
                "Failed to coerce value to List<{Element}> for instance {Instance}; marking quality Bad",
                et, _instanceUniqueName);
            return false;
        }
    }

    private void HandleConnectionQualityChanged(ConnectionQualityChanged qualityChanged)
    {
        _logger.LogWarning("Connection {Connection} quality changed to {Quality} for instance {Instance}",
            qualityChanged.ConnectionName, qualityChanged.Quality, _instanceUniqueName);

        if (_configuration == null) return;

        // Mark all attributes bound to this connection with the new quality
        // and publish to the site stream so the debug view updates in real-time.
        // We intentionally do NOT notify script/alarm actors here — the value
        // hasn't changed, only the quality, and firing scripts/alarms would
        // cause spurious evaluations.
        var qualityStr = qualityChanged.Quality.ToString();
        foreach (var attr in _configuration.Attributes)
        {
            if (attr.BoundDataConnectionName == qualityChanged.ConnectionName &&
                !string.IsNullOrEmpty(attr.DataSourceReference))
            {
                _attributeQualities[attr.CanonicalName] = qualityStr;
                _attributeTimestamps[attr.CanonicalName] = qualityChanged.Timestamp;

                // Publish quality change to stream (current value, new quality)
                _attributes.TryGetValue(attr.CanonicalName, out var currentValue);
                _streamManager?.PublishAttributeValueChanged(new AttributeValueChanged(
                    _instanceUniqueName,
                    attr.DataSourceReference,
                    attr.CanonicalName,
                    currentValue,
                    qualityStr,
                    qualityChanged.Timestamp));
            }
        }
    }

    /// <summary>
    /// Subscribes to DCL for all data-sourced attributes. Groups tag paths by connection
    /// name and sends SubscribeTagsRequest to the DCL manager.
    /// </summary>
    private void SubscribeToDcl()
    {
        if (_dclManager == null || _configuration == null) return;

        // Group attributes by their bound connection name into the persistent
        // _tagsByConnection field so a retry can re-send the exact same request.
        _tagsByConnection.Clear();
        foreach (var attr in _configuration.Attributes)
        {
            if (string.IsNullOrEmpty(attr.DataSourceReference) ||
                string.IsNullOrEmpty(attr.BoundDataConnectionName))
                continue;

            // Record every attribute that references this tag path so a single
            // tag value update fans out to all of them.
            if (!_tagPathToAttributes.TryGetValue(attr.DataSourceReference, out var attrs))
            {
                attrs = new List<string>();
                _tagPathToAttributes[attr.DataSourceReference] = attrs;
            }
            attrs.Add(attr.CanonicalName);

            if (!_tagsByConnection.TryGetValue(attr.BoundDataConnectionName, out var connTags))
            {
                connTags = new List<string>();
                _tagsByConnection[attr.BoundDataConnectionName] = connTags;
            }
            // Subscribe each distinct tag path once per connection — a tag shared
            // by several attributes still needs only one DCL subscription.
            if (!connTags.Contains(attr.DataSourceReference))
                connTags.Add(attr.DataSourceReference);
        }

        // Send subscription requests to DCL for each connection (retry-armed).
        foreach (var connectionName in _tagsByConnection.Keys)
            SendTagSubscribe(connectionName);
    }

    /// <summary>
    /// Sends (or re-sends) the tag-subscribe request for one connection and arms a
    /// per-connection retry timer (S4/UA6). Arming on send means the retry fires
    /// whether the response is a failure OR is lost entirely; a Success reply
    /// cancels it. A superseded in-flight correlation id is overwritten so only the
    /// latest attempt's reply is honoured.
    /// </summary>
    private void SendTagSubscribe(string connectionName)
    {
        if (_dclManager == null) return;
        if (!_tagsByConnection.TryGetValue(connectionName, out var tagPaths) || tagPaths.Count == 0)
            return;

        var correlationId = Guid.NewGuid().ToString("N");
        _pendingTagSubscribes[correlationId] = connectionName;

        var request = new SubscribeTagsRequest(
            correlationId, _instanceUniqueName, connectionName, tagPaths, DateTimeOffset.UtcNow);
        _dclManager.Tell(request, Self);
        _logger.LogInformation(
            "Instance {Instance} subscribing to {Count} tags on connection {Connection} (corr={Corr})",
            _instanceUniqueName, tagPaths.Count, connectionName, correlationId);

        if (_tagSubscribeRetryTimers.Remove(connectionName, out var existing))
            existing.Cancel();
        _tagSubscribeRetryTimers[connectionName] = Context.System.Scheduler.ScheduleTellOnceCancelable(
            TimeSpan.FromMilliseconds(_options.TagSubscribeRetryIntervalMs), Self,
            new RetryTagSubscribe(connectionName), Self);
    }

    /// <summary>
    /// Handles the DCL's tag-subscribe response: on Success cancels the connection's
    /// retry timer; on failure logs + emits a site event and leaves the armed timer
    /// to re-send. A reply for an unknown/superseded correlation id is ignored.
    /// </summary>
    private void HandleSubscribeTagsResponse(SubscribeTagsResponse response)
    {
        if (!_pendingTagSubscribes.Remove(response.CorrelationId, out var connectionName))
            return; // stale/superseded reply — ignore

        if (response.Success)
        {
            if (_tagSubscribeRetryTimers.Remove(connectionName, out var timer))
                timer.Cancel();
            _logger.LogDebug(
                "Instance {Instance} tag subscription confirmed on connection {Connection}",
                _instanceUniqueName, connectionName);
        }
        else
        {
            _logger.LogWarning(
                "Instance {Instance} tag subscribe FAILED on connection {Connection}: {Error} — will retry",
                _instanceUniqueName, connectionName, response.ErrorMessage);
            _ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
                "instance_lifecycle", "Warning", _instanceUniqueName,
                $"InstanceActor:{_instanceUniqueName}",
                $"Tag subscribe failed on connection '{connectionName}': {response.ErrorMessage} — will retry");
            // Leave the armed retry timer to re-send.
        }
    }

    /// <summary>
    /// Handles alarm state changes from Alarm Actors.
    /// Updates in-memory alarm state and publishes to stream.
    /// </summary>
    private void HandleAlarmStateChanged(AlarmStateChanged changed)
    {
        _alarmStates[changed.AlarmName] = changed.State;
        _alarmTimestamps[changed.AlarmName] = changed.Timestamp;
        // Retain the full enriched event (Kind, Condition, native metadata) so the
        // DebugView snapshot reflects it — native alarms have no _alarmActors entry.
        _latestAlarmEvents[changed.AlarmName] = changed;

        // Publish to site-wide stream
        _streamManager?.PublishAlarmStateChanged(changed);
    }

    /// <summary>
    /// Evicts a native condition's key from the alarm-state maps once
    /// the owning <see cref="NativeAlarmActor"/> has dropped it from its mirror (after
    /// emitting the condition's final return-to-normal). Without this the
    /// <c>_latestAlarmEvents</c> map grows without bound on a source that mints a fresh
    /// <c>SourceReference</c> per occurrence (one permanently-retained Normal entry per
    /// distinct condition the instance has ever seen), leaking per-instance memory and
    /// bloating every DebugView snapshot.
    ///
    /// Native-only by construction: the key is a native condition's <c>SourceReference</c>.
    /// Computed-alarm keys (configuration-bounded) are never sent here and never removed.
    /// </summary>
    private void HandleNativeAlarmDropped(NativeAlarmDropped dropped)
    {
        _latestAlarmEvents.Remove(dropped.SourceReference);
        _alarmStates.Remove(dropped.SourceReference);
        _alarmTimestamps.Remove(dropped.SourceReference);
    }

    /// <summary>
    /// Serves the script-facing alarm snapshot behind <c>Alarms.CurrentAsync()</c>
    /// (MES alarm-status API §5.2). Reuses <see cref="BuildAlarmStatesSnapshot"/>, so a
    /// script sees exactly the alarm set the Debug View shows for the same instant —
    /// computed alarms, mirrored native conditions, and the configured-but-quiet native
    /// binding placeholders (the accessor's caller decides whether to filter those).
    ///
    /// <para>
    /// Deliberately does NOT build the attribute list the debug snapshot carries: a script
    /// polling its alarms should not pay to materialise every attribute value. The reply is
    /// a plain mirror of the retained <c>AlarmStateChanged</c> rows; the projection to
    /// <c>ScriptAlarm</c> happens in the accessor, keeping the actor free of script types.
    /// </para>
    /// </summary>
    private void HandleGetAlarmSnapshot(GetAlarmSnapshotRequest request)
    {
        Sender.Tell(new GetAlarmSnapshotResponse(
            request.CorrelationId,
            _instanceUniqueName,
            BuildAlarmStatesSnapshot(),
            DateTimeOffset.UtcNow));
    }

    /// <summary>
    /// Debug view subscribe — returns snapshot and begins streaming.
    /// </summary>
    private void HandleSubscribeDebugView(SubscribeDebugViewRequest request)
    {
        // Build snapshot from current state
        var now = DateTimeOffset.UtcNow;
        var attributeValues = _attributes.Select(kvp => new AttributeValueChanged(
            _instanceUniqueName,
            kvp.Key,
            kvp.Key,
            kvp.Value,
            _attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
            _attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();

        var snapshot = new DebugViewSnapshot(
            _instanceUniqueName,
            attributeValues,
            BuildAlarmStatesSnapshot(),
            DateTimeOffset.UtcNow);

        Sender.Tell(snapshot);

        _logger.LogDebug(
            "Debug view snapshot sent for {Instance}, correlationId={Id}",
            _instanceUniqueName, request.CorrelationId);
    }

    /// <summary>
    /// Builds the alarm-state list for a DebugView snapshot. Prefers the latest
    /// enriched <see cref="AlarmStateChanged"/> per alarm (computed alarms that have
    /// fired plus all native alarms) and falls back to a bare Normal projection for
    /// computed alarms that have not yet emitted an event.
    /// </summary>
    private List<AlarmStateChanged> BuildAlarmStatesSnapshot()
    {
        var states = _latestAlarmEvents.Values.ToList();

        foreach (var name in _alarmActors.Keys)
        {
            if (_latestAlarmEvents.ContainsKey(name))
            {
                continue;
            }

            states.Add(new AlarmStateChanged(
                _instanceUniqueName,
                name,
                _alarmStates.GetValueOrDefault(name, AlarmState.Normal),
                _alarmPriorities.GetValueOrDefault(name, 0),
                _alarmTimestamps.GetValueOrDefault(name, DateTimeOffset.UtcNow)));
        }

        // A native binding is "already represented" if any retained event carries its
        // canonical name — it renders in the Debug View via those condition rows. The
        // placeholder below covers ONLY bindings with zero retained events (truly quiet
        // since startup). Do NOT narrow this to State==Active: a binding holding a
        // retained Normal/resolved condition would then be rendered twice.
        var liveBindings = _latestAlarmEvents.Values
            .Where(e => !string.IsNullOrEmpty(e.NativeSourceCanonicalName))
            .Select(e => e.NativeSourceCanonicalName)
            .ToHashSet();

        foreach (var binding in _nativeAlarmActors.Keys)
        {
            if (liveBindings.Contains(binding)) continue;

            states.Add(new AlarmStateChanged(
                _instanceUniqueName, binding, AlarmState.Normal, 0,
                _alarmTimestamps.GetValueOrDefault(binding, DateTimeOffset.MinValue))
            {
                Kind = _nativeAlarmKinds.GetValueOrDefault(binding, AlarmKind.NativeOpcUa),
                NativeSourceCanonicalName = binding,
                IsConfiguredPlaceholder = true
            });
        }

        return states;
    }

    /// <summary>
    /// Debug view unsubscribe.
    /// This handler is a deliberate no-op acknowledgement: the Instance Actor holds
    /// no per-subscriber state. The real debug-stream subscription lifecycle lives in
    /// <see cref="ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming.SiteStreamManager"/>
    /// (Subscribe / Unsubscribe / RemoveSubscriber); the gRPC stream is torn down
    /// there when the central side cancels the call. Nothing is removed here.
    /// </summary>
    private void HandleUnsubscribeDebugView(UnsubscribeDebugViewRequest request)
    {
        // No subscription state in the Instance Actor — see the XML doc above.
        _logger.LogDebug(
            "Debug view unsubscribe for {Instance}, correlationId={Id} " +
            "(no-op; subscription teardown handled by SiteStreamManager)",
            _instanceUniqueName, request.CorrelationId);
    }

    /// <summary>
    /// One-shot debug snapshot — returns current state without registering a subscriber.
    /// </summary>
    private void HandleDebugSnapshot(DebugSnapshotRequest request)
    {
        var now = DateTimeOffset.UtcNow;

        // Alarms-only projection (WP2.3): the central live-alarm cache seeds/reconciles
        // through this same query surface and throws every attribute row away. Building
        // and shipping them is pure waste — one instance with a few hundred attributes
        // costs more on the wire than the whole alarm set of the site. The flag is
        // additive and defaults false, so the Debug View is untouched.
        var attributeValues = request.AlarmsOnly
            ? new List<AttributeValueChanged>()
            : _attributes.Select(kvp => new AttributeValueChanged(
                _instanceUniqueName,
                kvp.Key,
                kvp.Key,
                kvp.Value,
                _attributeQualities.GetValueOrDefault(kvp.Key, "Good"),
                _attributeTimestamps.GetValueOrDefault(kvp.Key, now))).ToList();

        var snapshot = new DebugViewSnapshot(
            _instanceUniqueName,
            attributeValues,
            BuildAlarmStatesSnapshot(),
            DateTimeOffset.UtcNow);

        Sender.Tell(snapshot);
    }

    /// <summary>
    /// Publishes attribute change to stream and notifies child Script/Alarm actors.
    /// Tell for attribute notifications (fire-and-forget, never blocks).
    /// </summary>
    /// <param name="changed">The attribute change to publish.</param>
    /// <param name="evaluateWaiters">
    /// WaitForAttribute (spec §4.2): when <c>true</c> (the default), registered
    /// <c>Attributes.WaitAsync</c> waiters on this attribute are re-evaluated against
    /// <paramref name="changed"/>'s value. Pass <c>false</c> on republish/quality-only
    /// paths that do NOT assign a new value to <c>_attributes[name]</c> (e.g. the
    /// List-coerce-failure Bad-quality republish, which publishes the OLD value) —
    /// otherwise an "any-change" waiter (or a waiter whose target equals the unchanged
    /// value) would spuriously fire even though nothing actually changed.
    /// </param>
    private void PublishAndNotifyChildren(AttributeValueChanged changed, bool evaluateWaiters = true)
    {
        // Publish to site-wide stream
        _streamManager?.PublishAttributeValueChanged(changed);

        // Route the change to exactly the children interested in it (P2): the all-changes
        // bucket (Expression scripts/alarms + fail-open children) plus the children monitoring
        // this specific attribute. Children still gate on their own trigger config — routing
        // is a fan-out optimization, not the correctness check.
        foreach (var child in _allChangeChildren)
        {
            child.Tell(changed);
        }
        if (_childrenByMonitoredAttribute.TryGetValue(changed.AttributeName, out var interested))
        {
            foreach (var child in interested)
            {
                child.Tell(changed);
            }
        }

        // WaitForAttribute (spec §4.2): re-evaluate any waiters on THIS attribute —
        // but ONLY when this publish reflects a real value change (evaluateWaiters).
        // The genuine value-change paths (HandleAttributeValueChanged, the scalar
        // DCL update path, HandleSetStaticAttributeCore) call it AFTER assigning
        // _attributes[name], so changed.Value is the just-applied current value.
        // Republish/quality-only paths (List-coerce-failure Bad-quality, which
        // publishes the OLD value) pass evaluateWaiters:false so an "any-change" or
        // unchanged-value-equality waiter does not spuriously fire (spec §4.2).
        // Iterate a snapshot so satisfied waiters can be removed during the loop;
        // each match cancels its scheduled timeout (so no stray WaitForAttributeTimeout
        // follows) and replies Matched=true.
        if (evaluateWaiters)
            ResolveMatchedWaiters(changed);
    }

    /// <summary>
    /// WaitForAttribute (spec §4.2): fires every registered waiter on
    /// <paramref name="changed"/>'s attribute whose test now passes against the
    /// just-applied value — cancelling its timeout, replying Matched, and removing
    /// it from the registry. A no-op when there are no waiters.
    ///
    /// <para>
    /// Each waiter's match test runs inside a per-waiter try/catch: a throwing
    /// script-supplied predicate (or codec lambda) must NOT abort the loop and
    /// strand sibling waiters on the same attribute, nor leave the throwing waiter
    /// registered with a live scheduled timeout. On throw we cancel that waiter's
    /// timeout, reply non-matched + ErrorMessage, remove it, and continue.
    /// </para>
    /// </summary>
    private void ResolveMatchedWaiters(AttributeValueChanged changed)
    {
        if (_attributeWaiters.Count == 0)
            return;

        // Snapshot the candidate waiters on THIS attribute. Iterating a snapshot
        // (and NOT evaluating the test inside the LINQ filter) keeps removal mid-loop
        // safe and ensures one throwing test cannot abort materialization for siblings.
        var candidates = _attributeWaiters
            .Where(kvp => kvp.Value.AttributeName == changed.AttributeName)
            .ToList();

        foreach (var (cid, pending) in candidates)
        {
            bool matched;
            try
            {
                // §4.2 quality gate ANDed with the value test, both INSIDE the guard:
                // in quality-gated mode a value reaching the target at Bad/Uncertain
                // quality is NOT a match — the waiter stays pending until it satisfies
                // the test at Good quality (or times out).
                matched =
                    (!pending.RequireGoodQuality
                        || string.Equals(changed.Quality, "Good", StringComparison.Ordinal))
                    && pending.Test(changed.Value);
            }
            catch (Exception ex)
            {
                _logger.LogWarning(ex,
                    "WaitForAttribute predicate threw while resolving waiter {CorrelationId} on {Instance}.{Attribute}; evicting it",
                    cid, _instanceUniqueName, changed.AttributeName);
                pending.Timeout.Cancel();
                pending.Replyer.Tell(new WaitForAttributeResponse(
                    cid, Matched: false, null, null, TimedOut: false,
                    ErrorMessage: "Wait predicate threw: " + ex.Message));
                _attributeWaiters.Remove(cid);
                continue;
            }

            if (!matched)
                continue;

            pending.Timeout.Cancel();
            pending.Replyer.Tell(new WaitForAttributeResponse(
                cid, Matched: true, changed.Value, changed.Quality, TimedOut: false));
            _attributeWaiters.Remove(cid);
        }
    }

    /// <summary>
    /// Applies static overrides loaded from SQLite on top of default values.
    /// </summary>
    private void HandleOverridesLoaded(LoadOverridesResult result)
    {
        if (result.Error != null)
        {
            _logger.LogWarning(
                "Failed to load static overrides for {Instance}: {Error}",
                _instanceUniqueName, result.Error);
            return;
        }

        foreach (var kvp in result.Overrides)
        {
            // Persisted override values are canonical strings — a JSON array
            // string for List attributes, a plain string for scalars. Decode List
            // overrides to a typed list (matching the config-default load), set
            // Bad quality on a malformed stored value, and never crash the actor.
            if (_resolvedAttributeByName.TryGetValue(kvp.Key, out var resolved)
                && resolved.IsList)
            {
                // Decode the stored List override (both old array-of-strings
                // and native-typed forms decode) and re-persist the native form if
                // the stored value is still in the OLD form. Re-encoding the decoded
                // list and comparing to the stored string detects old-form values
                // (native → native is byte-identical, so a native value is a no-op).
                // The re-persist is fire-and-forget and never throws into the actor.
                var decoded = DecodeAttributeValue(
                    resolved.DataType, resolved.ElementDataType, kvp.Value, kvp.Key);
                _attributes[kvp.Key] = decoded;
                if (decoded is null && !string.IsNullOrEmpty(kvp.Value))
                {
                    _attributeQualities[kvp.Key] = "Bad";
                }
                else if (decoded is not null)
                {
                    var native = AttributeValueCodec.Encode(decoded);
                    if (native != kvp.Value)   // stored value was old-form → normalize on disk
                    {
                        var key = kvp.Key;
                        var logger = _logger;
                        var instanceName = _instanceUniqueName;
                        _storage.SetStaticOverrideAsync(instanceName, key, native!)
                            .ContinueWith(t => logger.LogWarning(t.Exception?.GetBaseException(),
                                "Failed to normalize static override {Instance}.{Attr} to native JSON",
                                instanceName, key),
                                TaskContinuationOptions.OnlyOnFaulted);
                    }
                }
            }
            else
            {
                _attributes[kvp.Key] = kvp.Value;
            }
        }

        _logger.LogDebug(
            "Loaded {Count} static overrides for {Instance}",
            result.Overrides.Count, _instanceUniqueName);
    }

    /// <summary>
    /// Creates child Script Actors and Alarm Actors from the flattened configuration.
    /// Script Actors spawned per script definition.
    /// Alarm Actors spawned per alarm definition, as peers to Script Actors.
    /// Compile failures are rejected at deploy time by the Deployment Manager's
    /// validation gate; a failure reaching this point (startup load of a legacy
    /// config) is logged and the offending script/alarm skipped.
    ///
    /// Each child is seeded from a private point-in-time snapshot
    /// of <c>_attributes</c>, NOT the live dictionary. The snapshot is taken here on
    /// the Instance Actor thread, so it is race-free; handing the live mutable
    /// <see cref="System.Collections.Generic.Dictionary{TKey,TValue}"/> by reference
    /// would let a child constructor enumerate it on the child's mailbox thread while
    /// this actor mutates it in <c>HandleAttributeValueChanged</c>.
    /// </summary>
    /// <summary>
    /// Files a freshly-created child actor into the attribute-change routing maps (P2)
    /// according to its trigger classification. Native alarm actors are NOT routed here —
    /// they subscribe to their own native feed through the DCL and never react to computed
    /// attribute changes.
    /// </summary>
    private void RouteChild(IActorRef child, TriggerRoutingDecision decision)
    {
        switch (decision.Kind)
        {
            case TriggerRoutingKind.MonitoredAttribute:
                if (!_childrenByMonitoredAttribute.TryGetValue(decision.MonitoredAttribute!, out var list))
                {
                    list = new List<IActorRef>();
                    _childrenByMonitoredAttribute[decision.MonitoredAttribute!] = list;
                }
                list.Add(child);
                break;
            case TriggerRoutingKind.AllChanges:
                _allChangeChildren.Add(child);
                break;
            case TriggerRoutingKind.None:
                // Interval / call-only triggers never react to attribute changes.
                break;
        }
    }

    private void CreateChildActors()
    {
        if (_configuration == null) return;

        // Snapshot the live attribute dictionary once, on the
        // Instance Actor thread, before any child is constructed. Each child
        // Props closure captures this immutable copy instead of the mutable
        // _attributes field, so no child constructor ever enumerates a
        // dictionary this actor is concurrently mutating.
        var attributeSnapshot = new Dictionary<string, object?>(_attributes);

        // Create Script Actors
        foreach (var script in _configuration.Scripts)
        {
            var compilationResult = _compilationService.Compile(script.CanonicalName, script.Code);
            if (!compilationResult.IsSuccess)
            {
                _logger.LogError(
                    "Script '{Script}' on instance '{Instance}' failed to compile: {Errors}",
                    script.CanonicalName, _instanceUniqueName,
                    string.Join("; ", compilationResult.Errors));
                continue;
            }

            // Compile the trigger expression for Expression-triggered scripts.
            var triggerExpression = CompileTriggerExpression(
                script.TriggerType, script.TriggerConfiguration, $"script-trigger-{script.CanonicalName}");

            var props = Props.Create(() => new ScriptActor(
                script.CanonicalName,
                _instanceUniqueName,
                Self,
                compilationResult.CompiledScript,
                script,
                _sharedScriptLibrary,
                _options,
                _logger,
                triggerExpression,
                attributeSnapshot,
                _healthCollector,
                _serviceProvider));

            var actorRef = Context.ActorOf(props, $"script-{script.CanonicalName}");
            _scriptActors[script.CanonicalName] = actorRef;
            RouteChild(actorRef, TriggerRouting.ForScript(script.TriggerType, script.TriggerConfiguration));
        }

        // Create Alarm Actors
        foreach (var alarm in _configuration.Alarms)
        {
            Script<object?>? onTriggerScript = null;
            // The on-trigger script's per-script execution timeout,
            // captured from its ResolvedScript so the launched on-trigger run
            // can apply perScript ?? global. Null when there is no on-trigger script.
            int? onTriggerTimeoutSeconds = null;

            // Compile on-trigger script if defined
            if (!string.IsNullOrEmpty(alarm.OnTriggerScriptCanonicalName))
            {
                var triggerScriptDef = _configuration.Scripts
                    .FirstOrDefault(s => s.CanonicalName == alarm.OnTriggerScriptCanonicalName);

                if (triggerScriptDef != null)
                {
                    onTriggerTimeoutSeconds = triggerScriptDef.ExecutionTimeoutSeconds;
                    var result = _compilationService.Compile(
                        $"alarm-trigger-{alarm.CanonicalName}", triggerScriptDef.Code);
                    if (result.IsSuccess)
                    {
                        onTriggerScript = result.CompiledScript;
                    }
                    else
                    {
                        _logger.LogWarning(
                            "Alarm trigger script for {Alarm} on {Instance} failed to compile",
                            alarm.CanonicalName, _instanceUniqueName);
                    }
                }
            }

            // Compile the trigger expression for Expression-triggered alarms.
            var triggerExpression = CompileTriggerExpression(
                alarm.TriggerType, alarm.TriggerConfiguration, $"alarm-trigger-expr-{alarm.CanonicalName}");

            var props = Props.Create(() => new AlarmActor(
                alarm.CanonicalName,
                _instanceUniqueName,
                Self,
                alarm,
                onTriggerScript,
                _sharedScriptLibrary,
                _options,
                _logger,
                triggerExpression,
                attributeSnapshot,
                _healthCollector,
                _serviceProvider,
                // Per-script timeout for the alarm on-trigger script.
                onTriggerTimeoutSeconds));

            var actorRef = Context.ActorOf(props, $"alarm-{alarm.CanonicalName}");
            _alarmActors[alarm.CanonicalName] = actorRef;
            _alarmPriorities[alarm.CanonicalName] = alarm.PriorityLevel;
            _alarmTimestamps[alarm.CanonicalName] = DateTimeOffset.UtcNow;
            RouteChild(actorRef, TriggerRouting.ForAlarm(alarm.TriggerType, alarm.TriggerConfiguration));
        }

        // Create Native Alarm Actors — read-only mirror of each bound source's
        // native alarms (peers to the computed Alarm Actors). They subscribe through
        // the DCL manager, so without one (e.g. in isolated tests) they are skipped.
        foreach (var nativeSource in _configuration.NativeAlarmSources)
        {
            if (_dclManager == null)
            {
                _logger.LogWarning(
                    "Instance {Instance}: native alarm source {Source} skipped — no DCL manager available",
                    _instanceUniqueName, nativeSource.CanonicalName);
                continue;
            }

            var nativeKind = ResolveNativeKind(nativeSource.ConnectionName);
            var props = Props.Create(() => new NativeAlarmActor(
                nativeSource,
                _instanceUniqueName,
                Self,
                _dclManager,
                _storage,
                _options,
                _logger,
                nativeKind,
                _serviceProvider));

            var actorRef = Context.ActorOf(props, $"native-alarm-{nativeSource.CanonicalName}");
            _nativeAlarmActors[nativeSource.CanonicalName] = actorRef;
            // Same kind handed to the NativeAlarmActor — used to stamp the
            // (cosmetic) kind badge on placeholder rows for quiet bindings.
            _nativeAlarmKinds[nativeSource.CanonicalName] = nativeKind;
        }

        _logger.LogInformation(
            "Instance {Instance}: created {Scripts} script actors, {Alarms} alarm actors, {NativeAlarms} native alarm actors",
            _instanceUniqueName, _scriptActors.Count, _alarmActors.Count, _nativeAlarmActors.Count);
    }

    /// <summary>
    /// Maps a bound connection's protocol to the native alarm kind stamped on
    /// emitted events. MxAccess Gateway connections yield
    /// <see cref="AlarmKind.NativeMxAccess"/>; everything else (OPC UA) yields
    /// <see cref="AlarmKind.NativeOpcUa"/>.
    /// </summary>
    private AlarmKind ResolveNativeKind(string connectionName)
    {
        var protocol = _configuration?.Connections is { } connections
            && connections.TryGetValue(connectionName, out var cfg)
            ? cfg.Protocol
            : null;

        return protocol != null && protocol.Contains("Mx", StringComparison.OrdinalIgnoreCase)
            ? AlarmKind.NativeMxAccess
            : AlarmKind.NativeOpcUa;
    }

    /// <summary>
    /// Compiles the boolean trigger expression for an Expression-triggered
    /// script or alarm. Returns null for non-Expression triggers, a blank
    /// expression, or a compilation failure (logged) — in which case the
    /// trigger is inert and the actor still starts.
    /// </summary>
    private Script<object?>? CompileTriggerExpression(
        string? triggerType, string? triggerConfigJson, string compileName)
    {
        if (!string.Equals(triggerType, "Expression", StringComparison.OrdinalIgnoreCase))
            return null;

        var expression = TriggerExpressionGlobals.ExtractExpression(triggerConfigJson);
        if (expression == null)
            return null;

        var result = _compilationService.CompileTriggerExpression(compileName, expression);
        if (result.IsSuccess)
            return result.CompiledScript;

        _logger.LogError(
            "Trigger expression for {Name} on {Instance} failed to compile: {Errors}",
            compileName, _instanceUniqueName, string.Join("; ", result.Errors));
        return null;
    }

    /// <summary>
    /// Read-only access to current attribute count (for testing/diagnostics).
    /// </summary>
    public int AttributeCount => _attributes.Count;

    /// <summary>
    /// Read-only access to script actor count (for testing/diagnostics).
    /// </summary>
    public int ScriptActorCount => _scriptActors.Count;

    /// <summary>
    /// Read-only access to alarm actor count (for testing/diagnostics).
    /// </summary>
    public int AlarmActorCount => _alarmActors.Count;

    /// <summary>
    /// Read-only view of the per-attribute child routing map (P2, for testing/diagnostics).
    /// </summary>
    internal IReadOnlyDictionary<string, List<IActorRef>> ChildrenByMonitoredAttribute => _childrenByMonitoredAttribute;

    /// <summary>
    /// Read-only view of the all-changes child routing bucket (P2, for testing/diagnostics).
    /// </summary>
    internal IReadOnlyList<IActorRef> AllChangeChildren => _allChangeChildren;

    /// <summary>
    /// Internal message for async override loading result.
    /// </summary>
    internal record LoadOverridesResult(Dictionary<string, string> Overrides, string? Error);

    /// <summary>
    /// WaitForAttribute (spec §4.2): one registered, not-yet-satisfied waiter.
    /// </summary>
    /// <param name="AttributeName">The attribute this waiter watches (scope-resolved).</param>
    /// <param name="Test">The match test (decoded-target equality OR site-local predicate OR any-change).</param>
    /// <param name="Replyer">The original sender to reply to on match / timeout.</param>
    /// <param name="Timeout">The scheduled timeout handle, canceled on match.</param>
    /// <param name="RequireGoodQuality">
    /// Quality-gated ("Good"-only) mode (spec §4.2): when <c>true</c>, the resolve
    /// loop additionally requires <c>changed.Quality == "Good"</c> before the test
    /// can match.
    /// </param>
    private sealed record PendingWait(
        string AttributeName,
        Func<object?, bool> Test,
        IActorRef Replyer,
        ICancelable Timeout,
        bool RequireGoodQuality);

    /// <summary>
    /// Self-scheduled tick (S4/UA6): re-send the tag-subscribe request for one
    /// connection whose previous attempt failed or whose response was lost.
    /// </summary>
    private sealed record RetryTagSubscribe(string ConnectionName);

    /// <summary>Self-tell that fires the coalesced static-override persist flush (P4, arch-review WP1.5).</summary>
    private sealed class FlushStaticOverrides
    {
        public static readonly FlushStaticOverrides Instance = new();
        private FlushStaticOverrides() { }
    }
}
