using Akka.Actor;
using Akka.Cluster;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Instance;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.ScriptExecution;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.SiteEventLogging;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Deployment;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Messages;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Persistence;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Repositories;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Scripts;
using ZB.MOM.WW.ScadaBridge.SiteRuntime.Streaming;

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

/// <summary>
/// Site-side Deployment Manager — runs as a cluster singleton within the site cluster.
/// On startup, reads all deployed configs from SQLite and creates Instance Actors
/// for enabled instances in staggered batches.
///
/// Handles: DeployInstance, DisableInstance, EnableInstance, DeleteInstance.
///
/// Supervision strategy: OneForOneStrategy with Resume for Instance Actors
/// so that a single instance failure does not cascade to siblings.
/// </summary>
public class DeploymentManagerActor : ReceiveActor, IWithTimers
{
    private readonly SiteStorageService _storage;
    private readonly ScriptCompilationService _compilationService;
    // Pure, off-thread deploy-time compile gate. A site-side compile failure
    // must reject the deployment (spec: no partial state applied), so every
    // DeployInstanceCommand is validated before any actor/persistence work.
    private readonly DeployCompileValidator _deployCompileValidator;
    private readonly SharedScriptLibrary _sharedScriptLibrary;
    /// <summary>
    /// WP2.6a: the shared cache backing <c>SiteExternalSystemRepository</c> reads.
    /// Invalidated wholesale after <see cref="HandleDeployArtifacts"/> applies
    /// external-system changes so a script's next call re-reads fresh definitions/method
    /// lists instead of serving a stale cached snapshot. Null in tests that do not wire
    /// external-system caching — the invalidation call is then simply skipped.
    /// </summary>
    private readonly ExternalSystemDefinitionCache? _externalSystemCache;
    private readonly SiteStreamManager? _streamManager;
    private readonly SiteRuntimeOptions _options;
    private readonly ILogger<DeploymentManagerActor> _logger;
    /// <summary>
    /// Shared logger factory used to mint <see cref="InstanceActor"/> loggers.
    /// Reused across every <see cref="CreateInstanceActor"/>
    /// call rather than newing a per-instance factory that is never disposed.
    /// When the host injects its configured factory the Instance Actor logs are
    /// routed through the application's logging providers.
    /// </summary>
    private readonly ILoggerFactory _loggerFactory;
    private readonly IActorRef? _dclManager;
    private readonly ISiteHealthCollector? _healthCollector;
    private readonly IServiceProvider? _serviceProvider;
    /// <summary>
    /// Notify-and-fetch: fetches a deployment's flattened config from central
    /// over HTTP when a <see cref="RefreshDeploymentCommand"/> arrives. Optional — null on
    /// nodes/tests that never receive a refresh; the active site path supplies it via DI.
    /// </summary>
    private readonly IDeploymentConfigFetcher? _configFetcher;
    /// <summary>
    /// Delay before re-attempting the startup deployed-config load after a
    /// transient failure (S5). Fixed-interval, unlimited retries — consistent with
    /// the system's fixed-interval retry philosophy. Overridable via the ctor for tests.
    /// </summary>
    private readonly TimeSpan _startupLoadRetryInterval;
    /// <summary>
    /// Loads all deployed configs at startup. Defaults to
    /// <see cref="SiteStorageService.GetAllDeployedConfigsAsync"/>; overridable via the
    /// ctor so a test can drive a transient load failure (S5).
    /// </summary>
    private readonly Func<Task<List<DeployedInstance>>> _configLoader;
    /// <summary>
    /// Set once the startup deployed-config load has succeeded. Guards against a
    /// stale <see cref="RetryStartupLoad"/> re-running the load after success.
    /// </summary>
    private bool _startupLoadCompleted;
    private readonly Dictionary<string, IActorRef> _instanceActors = new();
    /// <summary>
    /// Tracks Instance Actors that are terminating as part of a redeployment, keyed by
    /// the terminating actor ref. The buffered command is applied once <see cref="Terminated"/>
    /// confirms the child has fully stopped.
    /// </summary>
    private readonly Dictionary<IActorRef, PendingRedeploy> _pendingRedeploys = new();

    /// <summary>
    /// Name → terminating actor ref shadow of <see cref="_pendingRedeploys"/>.
    /// Required because a third <see cref="DeployInstanceCommand"/> for the same instance
    /// arriving WHILE a redeploy is still mid-termination would otherwise see
    /// <c>_instanceActors.TryGetValue == false</c> and fall through to
    /// <see cref="ApplyDeployment"/> + <see cref="CreateInstanceActor"/>, where
    /// <c>Context.ActorOf(props, instanceName)</c> throws <c>InvalidActorNameException</c>
    /// — the child name is still registered until the <see cref="Terminated"/> signal fires.
    /// The supervisor's Stop directive then drops the deploy command silently, leaving the
    /// deployer waiting forever and persistence dangling. The shadow index lets
    /// <see cref="HandleDeploy"/> detect the mid-termination state and overwrite the
    /// buffered pending command (last-write-wins) instead of trying to create a fresh actor.
    /// Cleared in <see cref="HandleTerminated"/> alongside <see cref="_pendingRedeploys"/>.
    /// </summary>
    private readonly Dictionary<string, IActorRef> _terminatingActorsByName = new();
    /// <summary>
    /// Authoritative set of deployed instance config names (enabled
    /// AND disabled). The deployed/disabled health counts are derived from this set's
    /// size, so add-on-deploy / remove-on-delete keeps the count correct for every
    /// path — including deleting a DISABLED instance, which has a config row but is
    /// absent from both <see cref="_instanceActors"/> and <see cref="_terminatingActorsByName"/>.
    /// </summary>
    private readonly HashSet<string> _deployedInstanceNames = new();

    /// <summary>
    /// Deployments awaiting their persistence result, keyed by instance name (S6).
    /// The entry is added when the Instance Actor is created optimistically and removed
    /// by whichever of two paths fires first:
    /// <list type="bullet">
    /// <item><see cref="HandleDeployPersistenceResult"/> on a normal persistence outcome,
    /// which then replies Success/Failed;</item>
    /// <item>the <see cref="HandleTerminated"/> S6 fallback when the actor dies during
    /// init, which replies Failed and rolls back the optimistic state.</item>
    /// </list>
    /// Removing the entry in exactly one of those paths guarantees the deployer receives
    /// exactly one <see cref="DeploymentStatusResponse"/> regardless of ordering.
    /// </summary>
    private readonly Dictionary<string, InFlightDeploy> _inFlightDeployments = new();

    /// <summary>
    /// Instances whose deployment was failed by the S6 Terminated fallback WHILE their
    /// optimistic <c>StoreDeployedConfigAsync</c> was still in flight. The persisted-row
    /// rollback must run AFTER the store commits (the two are independent background
    /// tasks with no ordering guarantee), so the removal is deferred here and executed by
    /// <see cref="HandleDeployPersistenceResult"/> when the store's result finally lands.
    /// </summary>
    private readonly HashSet<string> _initFailedPendingRowRemoval = new();

    /// <summary>
    /// WP3.1 warm-then-gate: instances whose deploy compile is being warmed off the actor
    /// thread, keyed by instance name. Presence is the per-instance in-flight guard that keeps
    /// same-instance command ordering intact while OTHER instances' commands flow freely.
    /// Entries are added in <see cref="HandleDeploy"/> and removed in
    /// <see cref="HandleDeployCompileWarmed"/> — one or the other always runs, because the
    /// warm task pipes its result back unconditionally (it swallows its own exceptions).
    /// </summary>
    private readonly Dictionary<string, DeployWarmState> _deployWarms = new();

    /// <summary>
    /// WP3.1: script-execution scheduler seam (#18) — the pool this actor grows as instances
    /// deploy. Null selects the process-wide shared scheduler; tests inject their own so
    /// sizing assertions do not perturb (or depend on) the process-wide singleton.
    /// </summary>
    private readonly ScriptExecutionScheduler? _scriptScheduler;

    /// <summary>Akka timer scheduler injected by the framework via <see cref="IWithTimers"/>.</summary>
    public ITimerScheduler Timers { get; set; } = null!;

    /// <summary>Initializes the actor, wires all message handlers, and prepares for staggered Instance Actor startup.</summary>
    /// <param name="storage">Site SQLite storage service for deployed configuration persistence.</param>
    /// <param name="compilationService">Script compilation service used when creating Instance Actors.</param>
    /// <param name="sharedScriptLibrary">Shared script library compiled before Instance Actors are created.</param>
    /// <param name="streamManager">Optional site-wide stream manager; null in unit tests.</param>
    /// <param name="options">Site runtime options (startup batch size, stagger interval, etc.).</param>
    /// <param name="logger">Logger instance.</param>
    /// <param name="dclManager">Optional Data Connection Layer manager actor.</param>
    /// <param name="replicationActor">Optional standby replication actor.</param>
    /// <param name="healthCollector">Optional site health collector.</param>
    /// <param name="serviceProvider">Optional service provider for resolving per-instance services.</param>
    /// <param name="loggerFactory">Optional logger factory for creating Instance Actor loggers.</param>
    /// <param name="configFetcher">
    /// Optional notify-and-fetch config fetcher. Required for the
    /// <see cref="RefreshDeploymentCommand"/> path; null on nodes/tests that never
    /// receive a refresh.
    /// </param>
    /// <param name="startupLoadRetryInterval">Optional retry interval used when loading
    /// deployed configuration at startup; defaults to 5 seconds when null.</param>
    /// <param name="configLoader">Optional override for loading all deployed configurations
    /// at startup; defaults to reading from <paramref name="storage"/>. Primarily for tests.</param>
    /// <param name="externalSystemCache">
    /// WP2.6a: the shared <see cref="ExternalSystemDefinitionCache"/> backing
    /// <c>SiteExternalSystemRepository</c> reads, invalidated after this actor applies
    /// external-system changes. Optional/null in tests that do not exercise external-system
    /// caching.
    /// </param>
    /// <param name="scriptScheduler">
    /// WP3.1: optional script-execution scheduler override (#18). This actor grows the pool
    /// towards <see cref="ScriptExecutionScheduler.ComputeTargetThreads"/> on every instance-
    /// count change; null uses the process-wide shared scheduler.
    /// </param>
    public DeploymentManagerActor(
        SiteStorageService storage,
        ScriptCompilationService compilationService,
        SharedScriptLibrary sharedScriptLibrary,
        SiteStreamManager? streamManager,
        SiteRuntimeOptions options,
        ILogger<DeploymentManagerActor> logger,
        IActorRef? dclManager = null,
        ISiteHealthCollector? healthCollector = null,
        IServiceProvider? serviceProvider = null,
        ILoggerFactory? loggerFactory = null,
        IDeploymentConfigFetcher? configFetcher = null,
        TimeSpan? startupLoadRetryInterval = null,
        Func<Task<List<DeployedInstance>>>? configLoader = null,
        ExternalSystemDefinitionCache? externalSystemCache = null,
        ScriptExecutionScheduler? scriptScheduler = null)
    {
        _scriptScheduler = scriptScheduler;
        _storage = storage;
        _compilationService = compilationService;
        _deployCompileValidator = new DeployCompileValidator(compilationService);
        _sharedScriptLibrary = sharedScriptLibrary;
        _externalSystemCache = externalSystemCache;
        _streamManager = streamManager;
        _options = options;
        _dclManager = dclManager;
        _healthCollector = healthCollector;
        _serviceProvider = serviceProvider;
        _configFetcher = configFetcher;
        _startupLoadRetryInterval = startupLoadRetryInterval ?? TimeSpan.FromSeconds(5);
        _configLoader = configLoader ?? (() => _storage.GetAllDeployedConfigsAsync());
        _logger = logger;
        // Reuse a single logger factory for all Instance Actors.
        // Prefer an explicitly injected factory, fall back to one resolved from
        // the service provider, and only as a last resort use NullLoggerFactory —
        // never a per-instance `new LoggerFactory()` that leaks undisposed.
        _loggerFactory = loggerFactory
            ?? serviceProvider?.GetService(typeof(ILoggerFactory)) as ILoggerFactory
            ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance;

        // Lifecycle commands
        Receive<DeployInstanceCommand>(cmd => HandleDeploy(cmd, Sender));
        Receive<DisableInstanceCommand>(cmd => HandleDisable(cmd, Sender));
        Receive<EnableInstanceCommand>(cmd => HandleEnable(cmd, Sender));
        Receive<DeleteInstanceCommand>(cmd => HandleDelete(cmd, Sender));

        // WP3.1 warm-then-gate: the off-thread compile warm for a deploy has finished, so
        // the (now all-cache-hit) synchronous compile gate can run on the actor thread.
        Receive<DeployCompileWarmed>(HandleDeployCompileWarmed);
        // WP3.1: a staggered startup batch's compiles have been pre-warmed off-thread;
        // create the batch's Instance Actors now that their PreStart compiles are hits.
        Receive<BatchCompileWarmed>(msg => CreateInstanceActorBatch(msg.Batch));

        // Notify-and-fetch: central sends a small RefreshDeploymentCommand;
        // the active singleton fetches the flattened config over HTTP, then reuses the
        // existing apply path. The two internal results carry the fetched config (or the
        // fetch error) back onto the actor thread along with the captured original sender.
        Receive<RefreshDeploymentCommand>(HandleRefreshDeployment);
        Receive<RefreshFetched>(HandleRefreshFetched);
        Receive<RefreshFetchFailed>(HandleRefreshFetchFailed);

        // Query-the-site-before-redeploy idempotency.
        // Central asks for the instance's currently-applied deployment identity
        // before re-sending a deployment whose prior record is stuck InProgress
        // or Failed due to a timeout.
        Receive<DeploymentStateQueryRequest>(HandleDeploymentStateQuery);

        // Handle system-wide artifact deployment
        Receive<DeployArtifactsCommand>(HandleDeployArtifacts);

        // Artifact-deploy DCL push, dispatched back from the
        // off-thread persistence task so the hash-cache mutation stays
        // actor-thread-confined.
        Receive<ApplyArtifactDataConnectionsToDcl>(HandleApplyArtifactDataConnectionsToDcl);

        // Debug View — route to Instance Actors
        Receive<SubscribeDebugViewRequest>(RouteDebugViewSubscribe);
        Receive<UnsubscribeDebugViewRequest>(RouteDebugViewUnsubscribe);
        Receive<DebugSnapshotRequest>(RouteDebugSnapshot);

        // Inbound API Route.To().Call() — route to Instance Actors
        Receive<RouteToCallRequest>(RouteInboundApiCall);
        Receive<RouteToGetAttributesRequest>(RouteInboundApiGetAttributes);
        Receive<RouteToSetAttributesRequest>(RouteInboundApiSetAttributes);
        Receive<RouteToWaitForAttributeRequest>(RouteInboundApiWaitForAttribute);

        // OPC UA Tag Browser — singleton-only re-forward to local /user/dcl-manager.
        // BrowseNodeCommand is routed to this singleton (active node) by
        // SiteCommunicationActor so the dcl-manager we forward to is guaranteed
        // to be the one holding the live DataConnectionActor children. ActorSelection
        // has no Forward() extension in this Akka.NET version, so we Tell with the
        // original Sender preserved (semantically identical to Forward).
        Receive<BrowseNodeCommand>(msg =>
            Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));

        // Test Bindings — same singleton-only re-forward as the browse handler
        // above. Routed to this singleton (active node) by SiteCommunicationActor
        // so the dcl-manager we forward to is guaranteed to hold the live
        // DataConnectionActor children.
        Receive<ReadTagValuesCommand>(msg =>
            Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));

        // OPC UA tag-picker address-space search, secured-write execute,
        // and endpoint Verify — same singleton-only re-forward as the browse
        // handler above. SiteCommunicationActor routes these to this singleton
        // (active node) so the local dcl-manager is the one holding the live
        // DataConnectionActor children (Search/Write route there by connection name;
        // Verify runs a temp probe in the manager). Tell with Sender preserved is
        // semantically identical to Forward (ActorSelection has no Forward()).
        Receive<SearchAddressSpaceCommand>(msg =>
            Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
        Receive<Commons.Messages.DataConnection.WriteTagRequest>(msg =>
            Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));
        Receive<VerifyEndpointCommand>(msg =>
            Context.ActorSelection("/user/dcl-manager").Tell(msg, Sender));

        // OPC UA server-certificate trust. Trust is site-local: the
        // trusted-peer PKI store is per-node, so a trust/remove MUST reach BOTH
        // site nodes (node-a + node-b) or they diverge across failover. This
        // singleton fans the corresponding per-node message out to the
        // CertStoreActor on EVERY site node; list is answered from this node.
        Receive<TrustServerCertCommand>(HandleTrustServerCert);
        Receive<RemoveServerCertCommand>(HandleRemoveServerCert);
        Receive<ListServerCertsCommand>(HandleListServerCerts);

        // UA1: cert-trust reconcile-on-join. A (re)joining site node may have
        // missed a trust broadcast while it was down, leaving its PKI store
        // divergent forever. The singleton reads its own trusted store and pushes
        // each cert to the joining node so the two per-node stores reconverge.
        Receive<ClusterEvent.MemberUp>(HandleMemberUp);
        Receive<SiteNodeJoined>(HandleSiteNodeJoined);
        Receive<LocalCertExport>(HandleLocalCertExport);

        // Internal startup messages
        Receive<StartupConfigsLoaded>(HandleStartupConfigsLoaded);
        // S5: re-attempt the startup deployed-config load after a transient
        // failure. Ignored once the load has already completed so a stale timer
        // (e.g. a failure retry that raced a subsequent success) cannot re-run it.
        Receive<RetryStartupLoad>(_ =>
        {
            if (_startupLoadCompleted)
                return;
            LoadDeployedConfigs();
        });
        Receive<SharedScriptsLoaded>(HandleSharedScriptsLoaded);
        Receive<StartNextBatch>(HandleStartNextBatch);

        // Internal enable result
        Receive<EnableResult>(HandleEnableResult);

        // Internal deploy persistence result
        Receive<DeployPersistenceResult>(HandleDeployPersistenceResult);

        // Instance Actor init confirmation — the second half of the deploy-success
        // join (S6). Success is only reported once both this and persistence land.
        Receive<InstanceActorInitialized>(HandleInstanceActorInitialized);

        // Terminated signal — drains a buffered redeployment once the previous
        // Instance Actor has fully stopped.
        Receive<Terminated>(HandleTerminated);
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        base.PreStart();
        _healthCollector?.SetActiveNode(true);
        _logger.LogInformation("DeploymentManagerActor starting — loading deployed configs from SQLite...");

        // UA1: watch for site nodes coming Up so we can reconcile cert trust onto
        // them. Cluster-only concern — a no-op in the unit-test ActorSystem which
        // has no cluster provider.
        if (ClusterAvailable(Context.System))
        {
            Cluster.Get(Context.System).Subscribe(Self,
                ClusterEvent.SubscriptionInitialStateMode.InitialStateAsEvents,
                typeof(ClusterEvent.MemberUp));
        }

        LoadDeployedConfigs();
    }

    /// <summary>
    /// True when this <see cref="ActorSystem"/> is running the cluster ref-provider.
    /// The unit-test harness uses the local provider, where <see cref="Cluster.Get"/>
    /// would throw — the cert reconcile-on-join subscription is skipped there.
    /// </summary>
    private static bool ClusterAvailable(ActorSystem system) =>
        system is ExtendedActorSystem ext &&
        ext.Provider.GetType().Name.Contains("Cluster", StringComparison.Ordinal);

    /// <summary>
    /// Loads all deployed configs off the actor thread and pipes the result back as a
    /// <see cref="StartupConfigsLoaded"/>. Called from <see cref="PreStart"/> and re-invoked
    /// by a <see cref="RetryStartupLoad"/> timer when a load fails (S5) — a transient SQLite
    /// failure must NOT leave the site as a silent zero-instance node, so the load is retried
    /// at a fixed interval until it succeeds.
    /// </summary>
    private void LoadDeployedConfigs()
    {
        _configLoader().ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
                return new StartupConfigsLoaded(t.Result, null);
            return new StartupConfigsLoaded([], t.Exception?.GetBaseException().Message);
        }).PipeTo(Self);
    }

    /// <inheritdoc />
    protected override void PostStop()
    {
        _healthCollector?.SetActiveNode(false);
        if (ClusterAvailable(Context.System))
        {
            Cluster.Get(Context.System).Unsubscribe(Self);
        }
        base.PostStop();
    }

    /// <inheritdoc />
    protected override SupervisorStrategy SupervisorStrategy()
    {
        return new OneForOneStrategy(
            maxNrOfRetries: -1,
            withinTimeRange: TimeSpan.FromMinutes(1),
            decider: Decider.From(ex =>
            {
                if (ex is ActorInitializationException)
                {
                    _logger.LogError(ex, "Instance Actor failed to initialize, stopping");
                    return Directive.Stop;
                }

                _logger.LogWarning(ex, "Instance Actor threw exception, resuming");
                return Directive.Resume;
            }));
    }

    /// <summary>
    /// Processes the loaded configs from SQLite.
    ///
    /// Shared scripts must be compiled before Instance Actors are
    /// created, but the SQLite read and Roslyn compilation must not block the
    /// singleton's mailbox. The compilation is run on a background task and a
    /// <see cref="SharedScriptsLoaded"/> message is piped back; only then does
    /// staggered Instance Actor creation begin. The deployed configs are stashed on the
    /// actor field in the meantime.
    /// </summary>
    private void HandleStartupConfigsLoaded(StartupConfigsLoaded msg)
    {
        if (msg.Error != null)
        {
            // S5: a transient SQLite failure must not strand the site as a silent
            // zero-instance node. Log and re-arm the load at a fixed interval; the
            // retry is unlimited and self-cancels once a load succeeds.
            _logger.LogError(
                "Failed to load deployed configs: {Error} — retrying in {Interval}",
                msg.Error, _startupLoadRetryInterval);
            Timers.StartSingleTimer(
                "startup-load-retry", RetryStartupLoad.Instance, _startupLoadRetryInterval);
            return;
        }

        // Mark the startup load complete so a stale retry timer is ignored.
        _startupLoadCompleted = true;

        var enabledConfigs = msg.Configs.Where(c => c.IsEnabled).ToList();
        _deployedInstanceNames.Clear();
        foreach (var c in msg.Configs)
            _deployedInstanceNames.Add(c.InstanceUniqueName);
        _logger.LogInformation(
            "Loaded {Total} deployed configs ({Enabled} enabled) from SQLite",
            msg.Configs.Count, enabledConfigs.Count);
        UpdateInstanceCounts();

        // Load and compile shared scripts off the actor thread, then resume startup.
        LoadSharedScriptsFromStorage(enabledConfigs);
    }

    /// <summary>
    /// Once shared scripts have been compiled off-thread, begins
    /// staggered Instance Actor creation for the enabled configs captured at startup.
    /// </summary>
    private void HandleSharedScriptsLoaded(SharedScriptsLoaded msg)
    {
        _logger.LogInformation(
            "Loaded {Compiled}/{Total} shared scripts from SQLite",
            msg.CompiledCount, msg.TotalCount);

        if (msg.EnabledConfigs.Count == 0)
            return;

        // Start the first batch immediately
        var batchState = new BatchState(msg.EnabledConfigs, 0);
        Self.Tell(new StartNextBatch(batchState));
    }

    /// <summary>
    /// WP3.1: pre-warms one staggered startup batch's script and trigger-expression compiles
    /// off the actor thread, then pipes <see cref="BatchCompileWarmed"/> so
    /// <see cref="CreateInstanceActorBatch"/> can create the batch's Instance Actors with
    /// every <c>PreStart</c> compile already a cache hit.
    ///
    /// <para>This closes the long-standing "per-instance compilation during staggered startup"
    /// gap: on failover, each Instance Actor used to Roslyn-compile its own scripts inside
    /// <c>PreStart</c>, serialising a site's whole recovery behind compilation. The cost is one
    /// extra message per batch.</para>
    /// </summary>
    private void HandleStartNextBatch(StartNextBatch msg)
    {
        var state = msg.State;
        var batchSize = _options.StartupBatchSize;
        var startIdx = state.NextIndex;
        var endIdx = Math.Min(startIdx + batchSize, state.Configs.Count);

        var validator = _deployCompileValidator;
        var batchConfigs = new string[endIdx - startIdx];
        for (var i = startIdx; i < endIdx; i++)
            batchConfigs[i - startIdx] = state.Configs[i].ConfigJson;

        Task.Run(() =>
        {
            foreach (var configJson in batchConfigs)
            {
                try
                {
                    // Verdict discarded: startup does not gate on compile failures (a failing
                    // script is logged by the Instance Actor and leaves the rest running).
                    // This call exists only to populate SiteScriptCompileCache.
                    validator.Validate(configJson);
                }
                catch
                {
                    // Best-effort warm — a throw here just means that instance's PreStart
                    // compiles the hard way, exactly as it did before WP3.1.
                }
            }

            return new BatchCompileWarmed(msg);
        }).PipeTo(Self);
    }

    /// <summary>
    /// Creates one batch's Instance Actors (after its compiles have been pre-warmed) and
    /// schedules the next batch, with a configurable delay between batches to prevent
    /// reconnection storms on failover.
    /// </summary>
    private void CreateInstanceActorBatch(StartNextBatch msg)
    {
        var state = msg.State;
        var batchSize = _options.StartupBatchSize;
        var startIdx = state.NextIndex;
        var endIdx = Math.Min(startIdx + batchSize, state.Configs.Count);

        _logger.LogDebug(
            "Creating Instance Actors batch [{Start}..{End}) of {Total}",
            startIdx, endIdx, state.Configs.Count);

        for (var i = startIdx; i < endIdx; i++)
        {
            var config = state.Configs[i];
            EnsureDclConnections(config.ConfigJson);
            CreateInstanceActor(config.InstanceUniqueName, config.ConfigJson);
        }

        UpdateInstanceCounts();

        // Schedule next batch if there are more, using Timers (IWithTimers)
        if (endIdx < state.Configs.Count)
        {
            var nextState = new BatchState(state.Configs, endIdx);
            Timers.StartSingleTimer(
                "startup-batch",
                new StartNextBatch(nextState),
                TimeSpan.FromMilliseconds(_options.StartupBatchDelayMs));
        }
        else
        {
            _logger.LogInformation(
                "All {Count} Instance Actors created", state.Configs.Count);
        }
    }

    /// <summary>
    /// Handles a new deployment: stores config in SQLite, clears previous static overrides,
    /// and creates or replaces the Instance Actor.
    ///
    /// Redeployment of an already-running instance must wait for the previous Instance
    /// Actor to fully terminate (including PostStop on its descendants) before the
    /// replacement is created — otherwise <see cref="Context.ActorOf"/> can collide on
    /// the still-registered child name. Instead of guessing with a fixed timer, the
    /// terminating child is watched and the in-flight command is buffered until the
    /// <see cref="Terminated"/> signal arrives.
    /// </summary>
    /// <param name="replyTo">
    /// The actor to reply to with the eventual <see cref="DeploymentStatusResponse"/>.
    /// Passed explicitly (rather than read from <see cref="ActorBase.Sender"/>) so the
    /// notify-and-fetch path (<see cref="HandleRefreshFetched"/>) can supply the ORIGINAL
    /// central sender after the async config fetch, where <c>Sender</c> is no longer valid.
    /// The redeploy-buffer path carries it on <see cref="PendingRedeploy"/> so the buffered
    /// apply still replies to the right actor.
    /// </param>
    private void HandleDeploy(DeployInstanceCommand command, IActorRef replyTo)
    {
        var instanceName = command.InstanceUniqueName;

        // WP3.1 warm-then-gate. The S3 gate below must stay synchronous on the actor thread
        // (mailbox FIFO is what makes redeploy-supersede and delete-during-redeploy correct),
        // but the Roslyn compile it performs used to hold the whole singleton — every OTHER
        // instance's commands stalled behind one instance's compile. So the compile is WARMED
        // off-thread first and the synchronous gate then re-runs as pure cache hits.
        //
        // Ordering is preserved by an explicit per-instance in-flight guard: while a warm is
        // in flight for instance X, further mutating commands for X are queued here rather
        // than racing ahead. Commands for OTHER instances flow freely, which is the whole
        // point — and safe, because cross-instance ordering was never guaranteed to callers.
        if (_deployWarms.TryGetValue(instanceName, out var warming))
        {
            // Nothing queued behind the pending deploy yet: plain last-write-wins, and the
            // displaced deployer is told it was superseded so it never waits out its Ask —
            // the same contract as the mid-termination redeploy buffer below.
            if (warming.Buffered.Count == 0)
            {
                warming.ReplyTo.Tell(new DeploymentStatusResponse(
                    warming.Command.DeploymentId, instanceName, DeploymentStatus.Failed,
                    $"superseded by newer deployment {command.DeploymentId} before the site compile gate ran",
                    DateTimeOffset.UtcNow));
                warming.Command = command;
                warming.ReplyTo = replyTo;
                // Warm the NEW config; the in-flight warm's result is recognised as stale by
                // the reference check in HandleDeployCompileWarmed and dropped.
                StartDeployCompileWarm(command);
                return;
            }

            // A delete/disable/enable is already queued behind the pending deploy —
            // superseding now would reorder it past this deploy, so queue instead.
            warming.Buffered.Add(new BufferedInstanceCommand(command, replyTo));
            return;
        }

        _deployWarms[instanceName] = new DeployWarmState(command, replyTo);
        StartDeployCompileWarm(command);
    }

    /// <summary>
    /// Compiles the deployment's scripts off the actor thread purely to populate the
    /// process-wide <see cref="SiteScriptCompileCache"/>, then pipes
    /// <see cref="DeployCompileWarmed"/> back so the authoritative gate can run on the actor
    /// thread against a warm cache. The warm is best-effort: its verdict is discarded and any
    /// exception swallowed, because <see cref="RunDeployGateAndProceed"/> re-derives the
    /// verdict (and reproduces any failure) exactly as it did before WP3.1.
    /// </summary>
    private void StartDeployCompileWarm(DeployInstanceCommand command)
    {
        var validator = _deployCompileValidator;
        var configJson = command.FlattenedConfigurationJson;
        Task.Run(() =>
        {
            try
            {
                validator.Validate(configJson);
            }
            catch (Exception ex)
            {
                _logger.LogDebug(ex,
                    "Deploy compile warm for {Instance} threw; the synchronous gate will re-run it",
                    command.InstanceUniqueName);
            }

            return new DeployCompileWarmed(command);
        }).PipeTo(Self);
    }

    /// <summary>
    /// Runs the authoritative compile gate for a warmed deployment on the actor thread, then
    /// drains any commands queued for that instance during the warm — inline and in arrival
    /// order, NOT by re-telling <c>Self</c>, which would put them behind messages that landed
    /// in the mailbox during the warm and so reorder same-instance commands.
    /// </summary>
    private void HandleDeployCompileWarmed(DeployCompileWarmed msg)
    {
        var instanceName = msg.Command.InstanceUniqueName;
        if (!_deployWarms.TryGetValue(instanceName, out var warming))
            return;

        // A newer deploy superseded this one while its warm was running; that supersede
        // started its own warm, so this result is stale and must not apply a dead command.
        if (!ReferenceEquals(warming.Command, msg.Command))
            return;

        _deployWarms.Remove(instanceName);

        RunDeployGateAndProceed(warming.Command, warming.ReplyTo);

        foreach (var queued in warming.Buffered)
            DispatchBufferedCommand(queued);
    }

    /// <summary>
    /// Queues a mutating lifecycle command for an instance whose deploy is mid-compile-warm.
    /// Returns <see langword="true"/> when the command was queued (the caller must return
    /// immediately), <see langword="false"/> when no warm is in flight and the caller should
    /// proceed normally.
    /// </summary>
    private bool TryBufferDuringDeployWarm(string instanceName, object command, IActorRef replyTo)
    {
        if (!_deployWarms.TryGetValue(instanceName, out var warming)) return false;
        warming.Buffered.Add(new BufferedInstanceCommand(command, replyTo));
        return true;
    }

    /// <summary>
    /// Re-dispatches one command queued during a deploy compile warm, restoring the original
    /// sender. A queued deploy legitimately opens a NEW warm window, in which case the
    /// commands after it re-queue behind that one — ordering still holds.
    /// </summary>
    private void DispatchBufferedCommand(BufferedInstanceCommand queued)
    {
        switch (queued.Command)
        {
            case DeployInstanceCommand deploy:
                HandleDeploy(deploy, queued.Sender);
                break;
            case DeleteInstanceCommand delete:
                HandleDelete(delete, queued.Sender);
                break;
            case DisableInstanceCommand disable:
                HandleDisable(disable, queued.Sender);
                break;
            case EnableInstanceCommand enable:
                HandleEnable(enable, queued.Sender);
                break;
            default:
                _logger.LogWarning(
                    "Unhandled buffered command type {Type} for a deploy compile warm — dropped",
                    queued.Command.GetType().Name);
                break;
        }
    }

    /// <summary>
    /// The authoritative site-side compile gate (S3) plus the deploy application. Reached
    /// only from <see cref="HandleDeployCompileWarmed"/>, i.e. after the same compile has
    /// been warmed off-thread.
    /// </summary>
    private void RunDeployGateAndProceed(DeployInstanceCommand command, IActorRef replyTo)
    {
        // Site-side compile gate (S3): a compile failure must reject the
        // deployment with NO partial state applied (no Instance Actor, no
        // persisted config) — the design spec's contract. Validation still runs
        // synchronously on the actor thread as a pure prefix step of the deploy
        // application, so the existing redeploy-supersede / delete-during-redeploy
        // ordering (which depends on strict mailbox FIFO) is preserved exactly.
        // WP3.1 removed the COST rather than the ordering: the same compile has
        // already been warmed off-thread (StartDeployCompileWarm), so this call is
        // pure cache hits and no longer holds the singleton — while the per-instance
        // warm guard keeps this instance's own command ordering intact. Redeploys and
        // multi-instance deploys of unchanged scripts hit the process-wide compile
        // cache (SiteScriptCompileCache) too, and the Instance Actor's PreStart reuses
        // the gate's compile (N4).
        var errors = _deployCompileValidator.Validate(command.FlattenedConfigurationJson);
        if (errors.Count > 0)
        {
            var instanceName = command.InstanceUniqueName;
            var joined = string.Join(" | ", errors);
            LogDeploymentEvent("Error", instanceName,
                $"Instance {instanceName} deploy rejected — site compile validation failed (deploymentId={command.DeploymentId})",
                joined);
            replyTo.Tell(new DeploymentStatusResponse(
                command.DeploymentId, instanceName, DeploymentStatus.Failed,
                "Script compilation failed on site: " + joined, DateTimeOffset.UtcNow));
            return;
        }

        ProceedWithDeploy(command, replyTo);
    }

    /// <summary>
    /// Core deploy application (redeploy-supersede + fresh apply), reached only
    /// after the synchronous compile gate in <see cref="HandleDeploy"/> passes.
    /// </summary>
    private void ProceedWithDeploy(DeployInstanceCommand command, IActorRef replyTo)
    {
        var instanceName = command.InstanceUniqueName;
        _logger.LogInformation(
            "Deploying instance {Instance}, deploymentId={DeploymentId}",
            instanceName, command.DeploymentId);

        // Redeployment replaces a running instance. Watch + stop the existing actor
        // and buffer this command until its Terminated signal confirms the child
        // (and its whole subtree) has fully stopped and freed its actor name.
        if (_instanceActors.TryGetValue(instanceName, out var existing))
        {
            _instanceActors.Remove(instanceName);
            _pendingRedeploys[existing] = new PendingRedeploy(command, replyTo);
            _terminatingActorsByName[instanceName] = existing;
            Context.Watch(existing);
            Context.Stop(existing);
            UpdateInstanceCounts();
            return;
        }

        // A deploy arriving while the previous redeploy is still
        // terminating (the Terminated signal hasn't fired yet) used to fall through
        // to ApplyDeployment(fresh), where Context.ActorOf would throw
        // InvalidActorNameException because the child name is still registered.
        // Detect the mid-termination state and overwrite the buffered pending
        // command (last-write-wins) so the latest deploy is applied when Terminated
        // arrives. The displaced sender is told Failed-superseded so it doesn't
        // wait forever.
        if (_terminatingActorsByName.TryGetValue(instanceName, out var terminatingRef))
        {
            if (_pendingRedeploys.TryGetValue(terminatingRef, out var displaced))
            {
                displaced.OriginalSender.Tell(new DeploymentStatusResponse(
                    displaced.Command.DeploymentId,
                    instanceName,
                    DeploymentStatus.Failed,
                    $"superseded by newer deployment {command.DeploymentId} before predecessor finished terminating",
                    DateTimeOffset.UtcNow));
            }
            _pendingRedeploys[terminatingRef] = new PendingRedeploy(command, replyTo);
            return;
        }

        // Fresh deployment — no existing actor to replace.
        ApplyDeployment(command, replyTo, isRedeploy: false);
    }

    /// <summary>
    /// Handles an Instance Actor's <see cref="Terminated"/> signal. Two cases:
    /// <list type="number">
    /// <item>The child was stopped as part of a redeployment — recreate it from the
    /// buffered <see cref="DeployInstanceCommand"/>.</item>
    /// <item>The child terminated OUTSIDE the redeploy flow — an init failure or crash.
    /// Evict the now-dead ref from the routing map so the singleton never routes to a
    /// stopped actor (S6).</item>
    /// </list>
    /// </summary>
    private void HandleTerminated(Terminated terminated)
    {
        // Case 1 — redeployment drain: the predecessor of a buffered redeploy has
        // fully stopped and freed its actor name.
        if (_pendingRedeploys.Remove(terminated.ActorRef, out var pending))
        {
            // Drop the name-keyed shadow now that the predecessor has
            // fully terminated and its actor name is free. Any deploy arriving after
            // this point sees neither _instanceActors[name] (cleared when we stopped
            // the predecessor) nor _terminatingActorsByName[name] (cleared here), so
            // ApplyDeployment + Context.ActorOf below safely reuses the name.
            _terminatingActorsByName.Remove(pending.Command.InstanceUniqueName);

            ApplyDeployment(pending.Command, pending.OriginalSender, isRedeploy: true);
            return;
        }

        // Case 2 — S6 fallback: an Instance Actor terminated unexpectedly (init
        // failure or crash). Expected stops (disable/delete/redeploy/persistence
        // rollback) all remove or replace the map entry BEFORE the child stops, so
        // the equality guard is false for them and this branch is inert. Only a dead
        // ref still mapped to this exact actor reaches here.
        var name = terminated.ActorRef.Path.Name;
        if (_instanceActors.TryGetValue(name, out var mapped) && mapped.Equals(terminated.ActorRef))
        {
            _instanceActors.Remove(name);
            UpdateInstanceCounts();
            _logger.LogError(
                "Instance Actor {Instance} terminated unexpectedly (init failure or crash) — removed from routing",
                name);
            LogDeploymentEvent("Error", name,
                $"Instance {name} terminated unexpectedly (init failure or crash) — removed from routing");

            // S6: if the death happened while a deployment was still in flight, the
            // actor died during init — fail that deployment (the persistence path must
            // NOT later report Success) and roll back the optimistically-applied state
            // so the site never advertises an instance it cannot durably recover.
            if (_inFlightDeployments.Remove(name, out var inflight))
            {
                inflight.ReplyTo.Tell(new DeploymentStatusResponse(
                    inflight.DeploymentId, name, DeploymentStatus.Failed,
                    "Instance actor failed during initialization — see site event log",
                    DateTimeOffset.UtcNow));

                if (!inflight.IsRedeploy)
                {
                    _deployedInstanceNames.Remove(name);
                    UpdateInstanceCounts();

                    // Remove the optimistically-persisted config row so a restart does
                    // not re-create the failing actor. If the store already committed
                    // (Persisted), remove it now; otherwise defer the removal until the
                    // in-flight store completes so the remove cannot race ahead of it.
                    if (inflight.Persisted)
                        RollbackPersistedConfig(name);
                    else
                        _initFailedPendingRowRemoval.Add(name);
                }
            }
        }
    }

    /// <summary>
    /// Notify-and-fetch: handles a small central→site
    /// <see cref="RefreshDeploymentCommand"/>. Fetches the deployment's flattened config
    /// from central over HTTP via <see cref="IDeploymentConfigFetcher"/>, then pipes the
    /// result back to self so the existing apply path runs on the actor thread with the
    /// ORIGINAL sender preserved (the central Ask's temp actor). The reply is the existing
    /// <see cref="DeploymentStatusResponse"/>, so the central deploy completes unchanged.
    /// </summary>
    private void HandleRefreshDeployment(RefreshDeploymentCommand cmd)
    {
        // Capture the Ask temp-actor sender BEFORE the async continuation: Akka's Sender
        // is only valid during synchronous message handling and is no longer the original
        // sender once the ContinueWith/PipeTo continuation runs on a thread-pool thread.
        var replyTo = Sender;

        if (_configFetcher is null)
        {
            replyTo.Tell(new DeploymentStatusResponse(
                cmd.DeploymentId, cmd.InstanceUniqueName, DeploymentStatus.Failed,
                "Deployment config fetcher not available on this node.", DateTimeOffset.UtcNow));
            return;
        }

        _logger.LogInformation(
            "Fetching config for deployment {DeploymentId} instance {Instance} (notify-and-fetch)",
            cmd.DeploymentId, cmd.InstanceUniqueName);

        // CancellationToken.None: the fetch is bounded by HttpClient.Timeout. On a singleton
        // handover mid-fetch the PipeTo lands in dead letters and the central Ask times out
        // (then reconciles) — acceptable, rare.
        _configFetcher.FetchAsync(cmd.CentralFetchBaseUrl, cmd.DeploymentId, cmd.FetchToken, CancellationToken.None)
            .ContinueWith(t => t.IsCompletedSuccessfully
                ? (object)new RefreshFetched(cmd, t.Result, replyTo)
                : new RefreshFetchFailed(cmd, t.Exception?.GetBaseException().Message ?? "fetch failed", replyTo))
            .PipeTo(Self);
    }

    /// <summary>
    /// Notify-and-fetch: the config fetch succeeded — reconstruct the in-process
    /// <see cref="DeployInstanceCommand"/> apply DTO and reuse the existing apply path,
    /// threading the original central sender through so the
    /// <see cref="DeploymentStatusResponse"/> reaches it.
    /// </summary>
    private void HandleRefreshFetched(RefreshFetched msg)
    {
        // Carry the central fetch coordinates on the apply DTO so they survive the whole
        // apply path (including the PendingRedeploy buffer) down to ApplyDeployment, where
        // they are replicated to the standby as an id-only notify-and-fetch message.
        var command = new DeployInstanceCommand(
            msg.Cmd.DeploymentId, msg.Cmd.InstanceUniqueName, msg.Cmd.RevisionHash,
            msg.ConfigJson, msg.Cmd.DeployedBy, msg.Cmd.Timestamp,
            msg.Cmd.CentralFetchBaseUrl, msg.Cmd.FetchToken);
        HandleDeploy(command, msg.ReplyTo);
    }

    /// <summary>
    /// Notify-and-fetch: the config fetch failed — report
    /// <see cref="DeploymentStatus.Failed"/> to the original central sender so the deploy
    /// completes (rather than the central Ask hanging to timeout). Nothing is applied.
    /// </summary>
    private void HandleRefreshFetchFailed(RefreshFetchFailed msg)
    {
        _logger.LogError(
            "Config fetch failed for deployment {DeploymentId} instance {Instance}: {Error}",
            msg.Cmd.DeploymentId, msg.Cmd.InstanceUniqueName, msg.Error);
        msg.ReplyTo.Tell(new DeploymentStatusResponse(
            msg.Cmd.DeploymentId, msg.Cmd.InstanceUniqueName, DeploymentStatus.Failed,
            $"Communication failure: {msg.Error}", DateTimeOffset.UtcNow));
    }

    /// <summary>
    /// Creates the Instance Actor, persists the config, and replies to the deployer.
    /// A redeployment is an update of an existing instance, so the deployed-instance
    /// counter is only incremented for genuinely new deployments.
    ///
    /// The deployer is <b>not</b> told <see cref="DeploymentStatus.Success"/>
    /// until SQLite persistence has committed. The site's deployed-config store is the
    /// durable source of truth — a config that was never persisted would be silently lost
    /// on the next restart/failover, so reporting Success before the row is committed is
    /// incorrect. The reply is sent from <see cref="HandleDeployPersistenceResult"/> once
    /// the persistence outcome is known.
    /// </summary>
    private void ApplyDeployment(DeployInstanceCommand command, IActorRef sender, bool isRedeploy)
    {
        var instanceName = command.InstanceUniqueName;

        // Ensure DCL connections exist for any data-sourced attributes
        EnsureDclConnections(command.FlattenedConfigurationJson);

        // Create the Instance Actor immediately
        CreateInstanceActor(instanceName, command.FlattenedConfigurationJson);
        if (!isRedeploy)
            _deployedInstanceNames.Add(instanceName);
        UpdateInstanceCounts();

        // Record the in-flight deployment so the deployer is answered exactly once:
        // Success only once BOTH persistence commits AND the actor confirms init, or
        // Failed via the S6 Terminated fallback if the Instance Actor dies during init.
        _inFlightDeployments[instanceName] = new InFlightDeploy
        {
            DeploymentId = command.DeploymentId,
            ReplyTo = sender,
            IsRedeploy = isRedeploy
        };

        // Persist to SQLite and clear static overrides asynchronously
        Task.Run(async () =>
        {
            await _storage.StoreDeployedConfigAsync(
                instanceName,
                command.FlattenedConfigurationJson,
                command.DeploymentId,
                command.RevisionHash,
                isEnabled: true);

            // Static overrides and mirrored native alarm state are reset on
            // redeployment per design decision — the new config may bind different
            // sources, and the source snapshot re-seeds the mirror on (re)subscribe.
            await _storage.ClearStaticOverridesAsync(instanceName);
            await _storage.ClearNativeAlarmsForInstanceAsync(instanceName);

            // No explicit replication step: deployed_configurations is a replicated table,
            // so the write above is captured and shipped to the peer like any other row.
            // This used to be a notify-and-fetch Tell — the standby was sent the deployment
            // id plus central fetch coordinates and pulled the config over HTTP itself,
            // because a large config would silently drop on the intra-site Akka hop's
            // 128 KB frame trap. Replication carries the row directly and has no such limit,
            // so the standby no longer fetches on deploy. (SiteReconciliationActor still
            // fetches at node startup when central reports gaps — a different path.)

            return new DeployPersistenceResult(
                command.DeploymentId, instanceName, true, null, sender, isRedeploy);
        }).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
                return t.Result;
            return new DeployPersistenceResult(
                command.DeploymentId, instanceName, false,
                t.Exception?.GetBaseException().Message, sender, isRedeploy);
        }).PipeTo(Self);
    }

    /// <summary>
    /// Records the persistence half of the deploy-success join. On a persistence
    /// failure the optimistically-created Instance Actor is stopped and the counter
    /// rolled back and the deployer is told <see cref="DeploymentStatus.Failed"/>. On
    /// success the in-flight entry is marked persisted and success is reported only once
    /// the actor has ALSO confirmed init (<see cref="TryCompleteDeploy"/>) — persistence
    /// can commit before the actor's async init has run or failed (S6).
    /// </summary>
    private void HandleDeployPersistenceResult(DeployPersistenceResult result)
    {
        // If the S6 Terminated fallback already failed this deployment (the Instance
        // Actor died during init), the in-flight entry is gone — swallow the now-stale
        // persistence result so the deployer is answered exactly once.
        if (!_inFlightDeployments.TryGetValue(result.InstanceName, out var inflight))
        {
            // The fallback deferred the persisted-row rollback because the store was
            // still in flight then. The store has now committed (Success), so remove
            // the orphan row — guaranteed to run after the store, never racing it.
            if (_initFailedPendingRowRemoval.Remove(result.InstanceName) && result.Success)
                RollbackPersistedConfig(result.InstanceName);

            _logger.LogDebug(
                "Persistence result for {Instance} arrived after the deployment was already " +
                "resolved (init-time termination) — ignoring (deploymentId={DeploymentId}, success={Success})",
                result.InstanceName, result.DeploymentId, result.Success);
            return;
        }

        if (result.Success)
        {
            // Persistence committed — record it and complete only if init also confirmed.
            inflight.Persisted = true;
            TryCompleteDeploy(result.InstanceName, inflight);
            return;
        }

        // Persistence genuinely failed — fail now regardless of init state.
        _inFlightDeployments.Remove(result.InstanceName);

        _logger.LogError(
            "Failed to persist deployment {DeploymentId} for {Instance}: {Error}",
            result.DeploymentId, result.InstanceName, result.Error);

        // Operational `deployment` event — deploy failed.
        LogDeploymentEvent("Error", result.InstanceName,
            $"Instance {result.InstanceName} deploy failed (deploymentId={result.DeploymentId})",
            result.Error);

        // Undo the optimistic actor creation and counter bump so the site does not
        // advertise an instance it cannot durably recover.
        if (_instanceActors.Remove(result.InstanceName, out var orphan))
            Context.Stop(orphan);
        if (!result.IsRedeploy)
            _deployedInstanceNames.Remove(result.InstanceName);
        UpdateInstanceCounts();

        inflight.ReplyTo.Tell(new DeploymentStatusResponse(
            result.DeploymentId,
            result.InstanceName,
            DeploymentStatus.Failed,
            result.Error ?? "Deployment persistence failed",
            DateTimeOffset.UtcNow));
    }

    /// <summary>
    /// Records the init half of the deploy-success join (S6): the Instance Actor
    /// finished PreStart and sent <see cref="InstanceActorInitialized"/>. Success is
    /// reported only once persistence has ALSO committed. Actors created outside a
    /// deployment (startup, enable) have no in-flight entry and are ignored here.
    /// </summary>
    private void HandleInstanceActorInitialized(InstanceActorInitialized msg)
    {
        if (_inFlightDeployments.TryGetValue(msg.InstanceUniqueName, out var inflight))
        {
            inflight.Initialized = true;
            TryCompleteDeploy(msg.InstanceUniqueName, inflight);
        }
    }

    /// <summary>
    /// Completes a deployment with <see cref="DeploymentStatus.Success"/> once BOTH the
    /// persistence result and the Instance Actor init confirmation have landed (S6).
    /// A no-op until both signals are present.
    /// </summary>
    private void TryCompleteDeploy(string instanceName, InFlightDeploy inflight)
    {
        if (!inflight.Persisted || !inflight.Initialized)
            return;

        _inFlightDeployments.Remove(instanceName);

        // Operational `deployment` event — deploy succeeded.
        LogDeploymentEvent("Info", instanceName,
            $"Instance {instanceName} deployed (deploymentId={inflight.DeploymentId})");

        inflight.ReplyTo.Tell(new DeploymentStatusResponse(
            inflight.DeploymentId,
            instanceName,
            DeploymentStatus.Success,
            null,
            DateTimeOffset.UtcNow));
    }

    /// <summary>
    /// Fire-and-forget removal of an optimistically-persisted deployed-config row after
    /// an init failure (S6). Only ever called once the row's write has committed, so it
    /// cannot race the store. A fault is logged, never thrown.
    /// </summary>
    private void RollbackPersistedConfig(string instanceName)
    {
        Task.Run(() => _storage.RemoveDeployedConfigAsync(instanceName)).ContinueWith(t =>
        {
            if (!t.IsCompletedSuccessfully)
                _logger.LogError(t.Exception?.GetBaseException(),
                    "Failed to roll back the persisted config for {Instance} after an init failure",
                    instanceName);
        });
    }

    /// <summary>
    /// Disables an instance: stops the actor and marks as disabled in SQLite.
    /// </summary>
    private void HandleDisable(DisableInstanceCommand command, IActorRef replyTo)
    {
        var instanceName = command.InstanceUniqueName;

        // WP3.1 warm-then-gate: a deploy for this instance is mid-compile-warm; queue this
        // command so same-instance ordering is preserved (see TryBufferDuringDeployWarm).
        if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;

        // A disable arriving mid-redeploy must cancel the buffered
        // redeploy. Otherwise HandleTerminated re-creates the Instance Actor and
        // re-stores its config with isEnabled: true when the predecessor terminates,
        // silently reverting the operator's disable back to enabled. Mirror the
        // last-write-wins handling in HandleDeploy/HandleDelete: drop the pending
        // command (so HandleTerminated returns early), clear the shadow, and tell the
        // displaced deployer it was superseded. The disable itself still persists
        // is_enabled = false below, which becomes the durable state.
        if (_terminatingActorsByName.TryGetValue(instanceName, out var terminatingRef))
        {
            if (_pendingRedeploys.Remove(terminatingRef, out var pending))
            {
                pending.OriginalSender.Tell(new DeploymentStatusResponse(
                    pending.Command.DeploymentId,
                    instanceName,
                    DeploymentStatus.Failed,
                    $"superseded by disable of {instanceName} before redeploy finished terminating",
                    DateTimeOffset.UtcNow));
            }
            _terminatingActorsByName.Remove(instanceName);
        }
        else if (_instanceActors.TryGetValue(instanceName, out var actor))
        {
            Context.Stop(actor);
            _instanceActors.Remove(instanceName);
        }

        UpdateInstanceCounts();

        var sender = replyTo;
        _storage.SetInstanceEnabledAsync(instanceName, false).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
            {
                // Operational `deployment` event — disable succeeded.
                LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} disabled");
            }
            else
            {
                LogDeploymentEvent("Error", instanceName,
                    $"Instance {instanceName} disable failed",
                    t.Exception?.GetBaseException().Message);
            }

            return new InstanceLifecycleResponse(
                command.CommandId,
                instanceName,
                t.IsCompletedSuccessfully,
                t.Exception?.GetBaseException().Message,
                DateTimeOffset.UtcNow);
        }).PipeTo(sender);

        _logger.LogInformation("Instance {Instance} disabled", instanceName);
    }

    /// <summary>
    /// Enables an instance: marks as enabled in SQLite and re-creates the Instance Actor
    /// from the stored config.
    /// </summary>
    private void HandleEnable(EnableInstanceCommand command, IActorRef replyTo)
    {
        var instanceName = command.InstanceUniqueName;

        // WP3.1 warm-then-gate: see TryBufferDuringDeployWarm.
        if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;

        var sender = replyTo;

        Task.Run(async () =>
        {
            await _storage.SetInstanceEnabledAsync(instanceName, true);
            var configs = await _storage.GetAllDeployedConfigsAsync();
            var config = configs.FirstOrDefault(c => c.InstanceUniqueName == instanceName);
            return new EnableResult(command, config, null, sender);
        }).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
                return t.Result;
            return new EnableResult(command, null, t.Exception?.GetBaseException().Message, sender);
        }).PipeTo(Self);
    }

    /// <summary>
    /// Processes enable result in the actor context (thread-safe).
    /// </summary>
    private void HandleEnableResult(EnableResult result)
    {
        var instanceName = result.Command.InstanceUniqueName;

        if (result.Error != null || result.Config == null)
        {
            var error = result.Error ?? $"No deployed config found for {instanceName}";
            // Operational `deployment` event — enable failed.
            LogDeploymentEvent("Error", instanceName,
                $"Instance {instanceName} enable failed", error);
            result.OriginalSender.Tell(new InstanceLifecycleResponse(
                result.Command.CommandId, instanceName, false, error, DateTimeOffset.UtcNow));
            return;
        }

        if (!_instanceActors.ContainsKey(instanceName))
        {
            CreateInstanceActor(instanceName, result.Config.ConfigJson);
        }
        UpdateInstanceCounts();

        // Operational `deployment` event — enable succeeded.
        LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} enabled");

        result.OriginalSender.Tell(new InstanceLifecycleResponse(
            result.Command.CommandId, instanceName, true, null, DateTimeOffset.UtcNow));

        _logger.LogInformation("Instance {Instance} enabled", instanceName);
    }

    /// <summary>
    /// Deletes an instance: stops the actor and removes config from SQLite.
    /// Note: store-and-forward messages are NOT cleared per design decision.
    /// </summary>
    private void HandleDelete(DeleteInstanceCommand command, IActorRef replyTo)
    {
        var instanceName = command.InstanceUniqueName;

        // WP3.1 warm-then-gate: see TryBufferDuringDeployWarm.
        if (TryBufferDuringDeployWarm(instanceName, command, replyTo)) return;

        // A delete arriving while a redeploy is still terminating must
        // be authoritative over the mid-redeploy bookkeeping. HandleDeploy already
        // removed the instance from _instanceActors and buffered a PendingRedeploy
        // keyed by the terminating ref. If we fall straight through to the
        // _instanceActors miss + unconditional decrement, the buffered redeploy is
        // left intact — so when Terminated fires, HandleTerminated calls
        // ApplyDeployment(isRedeploy: true) and RESURRECTS the just-deleted instance,
        // with the counter now inconsistent. Cancel the pending redeploy first.
        if (_terminatingActorsByName.TryGetValue(instanceName, out var terminatingRef))
        {
            // Drop the buffered command so HandleTerminated's _pendingRedeploys.Remove
            // misses and it returns early (no resurrection). Clear the shadow too.
            if (_pendingRedeploys.Remove(terminatingRef, out var pending))
            {
                pending.OriginalSender.Tell(new DeploymentStatusResponse(
                    pending.Command.DeploymentId,
                    instanceName,
                    DeploymentStatus.Failed,
                    $"superseded by delete of {instanceName} before redeploy finished terminating",
                    DateTimeOffset.UtcNow));
            }
            _terminatingActorsByName.Remove(instanceName);
            // The terminating predecessor is already being stopped by HandleDeploy;
            // no Context.Stop needed here.
        }
        else if (_instanceActors.TryGetValue(instanceName, out var actor))
        {
            Context.Stop(actor);
            _instanceActors.Remove(instanceName);
        }

        // The deployed count is derived from the authoritative set of
        // deployed config names, so removing the name here decrements it. Correct for a
        // live, mid-redeploy, OR DISABLED instance (a disabled instance has a config row
        // but is absent from both in-memory maps); a delete for a never-deployed instance
        // removes nothing and leaves the count unchanged. Supersedes the earlier
        // map-presence check, which leaked the count on disabled-instance deletes.
        _deployedInstanceNames.Remove(instanceName);
        UpdateInstanceCounts();

        var sender = replyTo;
        _storage.RemoveDeployedConfigAsync(instanceName).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
            {
                // Operational `deployment` event — delete succeeded.
                LogDeploymentEvent("Info", instanceName, $"Instance {instanceName} deleted");
            }
            else
            {
                LogDeploymentEvent("Error", instanceName,
                    $"Instance {instanceName} delete failed",
                    t.Exception?.GetBaseException().Message);
            }

            return new InstanceLifecycleResponse(
                command.CommandId,
                instanceName,
                t.IsCompletedSuccessfully,
                t.Exception?.GetBaseException().Message,
                DateTimeOffset.UtcNow);
        }).PipeTo(sender);

        _logger.LogInformation("Instance {Instance} deleted", instanceName);
    }

    /// <summary>
    /// Fire-and-forget a <c>deployment</c> operational event to the optional
    /// <see cref="ISiteEventLogger"/> on a deploy/enable/disable/delete outcome.
    /// Resolved optionally and never awaited so a logging failure cannot affect the
    /// deployment pipeline (matching the established ScriptActor / script-run
    /// pattern).
    /// <para>
    /// <b>Thread-safety:</b> the disable (<see cref="HandleDisable"/>) and delete
    /// (<see cref="HandleDelete"/>) paths call this from a
    /// <see cref="System.Threading.Tasks.Task.ContinueWith(System.Action{System.Threading.Tasks.Task})"/>
    /// continuation that runs on a thread-pool thread, NOT on the actor thread —
    /// so it must touch only immutable, thread-safe state. It does: the only
    /// field it reads is the <c>readonly _serviceProvider</c> captured at
    /// construction (the resolved <see cref="ISiteEventLogger"/> is a process
    /// singleton). No actor-private mutable state is referenced, which is what
    /// makes calling it off the actor thread safe.
    /// </para>
    /// </summary>
    private void LogDeploymentEvent(string severity, string instanceName, string message, string? details = null)
    {
        _ = _serviceProvider?.GetService<ISiteEventLogger>()?.LogEventAsync(
            "deployment", severity, instanceName, "DeploymentManagerActor", message, details);
    }

    /// <summary>
    /// Answers a central query for the instance's
    /// currently-applied deployment identity. The site's deployed-config store
    /// (SQLite) is the authoritative record — it covers both enabled and
    /// disabled instances, and survives node restart/failover. If the instance
    /// has no stored config, the response reports <c>IsDeployed = false</c> with
    /// null identity so central falls through to a normal deploy.
    /// </summary>
    private void HandleDeploymentStateQuery(DeploymentStateQueryRequest request)
    {
        var sender = Sender;
        var instanceName = request.InstanceUniqueName;

        _storage.GetAllDeployedConfigsAsync().ContinueWith(t =>
        {
            if (!t.IsCompletedSuccessfully)
            {
                _logger.LogError(
                    t.Exception?.GetBaseException(),
                    "Failed to read deployed configs for deployment state query of {Instance}",
                    instanceName);
                // Treat a storage read failure as "unknown" — central falls
                // through to a normal deploy and relies on site-side
                // stale-rejection as the safety net.
                return new DeploymentStateQueryResponse(
                    request.CorrelationId, instanceName, false, null, null, DateTimeOffset.UtcNow);
            }

            var config = t.Result.FirstOrDefault(c => c.InstanceUniqueName == instanceName);
            return config == null
                ? new DeploymentStateQueryResponse(
                    request.CorrelationId, instanceName, false, null, null, DateTimeOffset.UtcNow)
                : new DeploymentStateQueryResponse(
                    request.CorrelationId, instanceName, true,
                    config.DeploymentId, config.RevisionHash, DateTimeOffset.UtcNow);
        }).PipeTo(sender);
    }

    // ── OPC UA server-certificate trust ──

    /// <summary>
    /// The base cluster role every site node carries (in addition to its
    /// per-site role <c>site-{SiteId}</c>). Used to enumerate the site nodes a
    /// trust/remove must reach so node-a and node-b PKI stores stay in lock-step
    /// across failover. Matches the role string set in <c>NodeOptions.Role</c>
    /// for site hosts (see <c>AkkaHostedService.BuildRoles</c>).
    /// </summary>
    private const string SiteClusterRole = "Site";

    /// <summary>
    /// The per-node ask timeout for a cert broadcast. A node that does not ack
    /// within this window is reported as a partial failure (the command is not
    /// failed wholesale).
    /// </summary>
    private static readonly TimeSpan CertBroadcastTimeout = TimeSpan.FromSeconds(5);

    /// <summary>
    /// Broadcasts a trust to the per-node <see cref="CertStoreActor"/>
    /// on every Up site node so both PKI stores receive the certificate.
    /// </summary>
    private void HandleTrustServerCert(TrustServerCertCommand command) =>
        BroadcastToSiteCertStores(
            new WriteCertToLocalStore(command.DerBase64, command.Thumbprint),
            $"trust cert {command.Thumbprint} (connection {command.ConnectionName})");

    /// <summary>
    /// Broadcasts a remove to the per-node <see cref="CertStoreActor"/>
    /// on every Up site node so the certificate leaves both PKI stores.
    /// </summary>
    private void HandleRemoveServerCert(RemoveServerCertCommand command) =>
        BroadcastToSiteCertStores(
            new RemoveCertFromLocalStore(command.Thumbprint),
            $"remove cert {command.Thumbprint}");

    /// <summary>
    /// Lists this node's trusted + rejected PKI stores by asking the LOCAL
    /// <see cref="CertStoreActor"/> (the singleton's own node). The list reflects
    /// the active node's view; a trust broadcast keeps the standby in sync.
    /// </summary>
    private void HandleListServerCerts(ListServerCertsCommand command)
    {
        var sender = Sender;
        var local = Context.ActorSelection($"/user/{CertStoreActor.WellKnownName}");
        local.Ask<LocalCertOpAck>(new ListLocalCerts(), CertBroadcastTimeout)
            .ContinueWith(t =>
            {
                if (t.IsCompletedSuccessfully)
                {
                    var ack = t.Result;
                    return new CertTrustResult(ack.Success, ack.Error, ack.Certs);
                }

                var err = t.Exception?.GetBaseException().Message ?? "cert store did not respond";
                _logger.LogWarning("Local cert store list failed: {Error}", err);
                return new CertTrustResult(false, err, null);
            }).PipeTo(sender);
    }

    /// <summary>
    /// Fans <paramref name="localMessage"/> out to the <see cref="CertStoreActor"/>
    /// on every Up site node, asks each with a short timeout, and aggregates the
    /// acks into one <see cref="CertTrustResult"/>. A node that fails to ack is a
    /// partial failure (Success=false, first error reported) — it never throws,
    /// so a single unreachable standby cannot stall the singleton.
    /// </summary>
    private void BroadcastToSiteCertStores(object localMessage, string description)
    {
        var sender = Sender;
        var cluster = Cluster.Get(Context.System);
        var targets = cluster.State.Members
            .Where(m => m.Status == MemberStatus.Up && m.HasRole(SiteClusterRole))
            .Select(m => m.Address)
            .ToList();

        if (targets.Count == 0)
        {
            _logger.LogWarning("No Up site nodes found to {Description}; nothing trusted", description);
            sender.Tell(new CertTrustResult(false, "no site nodes available", null));
            return;
        }

        _logger.LogInformation("Broadcasting cert op to {Count} site node(s): {Description}",
            targets.Count, description);

        var asks = targets.Select(address =>
        {
            var path = new RootActorPath(address) / "user" / CertStoreActor.WellKnownName;
            return Context.ActorSelection(path)
                .Ask<LocalCertOpAck>(localMessage, CertBroadcastTimeout)
                .ContinueWith(t => t.IsCompletedSuccessfully
                    ? t.Result
                    : new LocalCertOpAck(false,
                        $"node {address} did not ack: {t.Exception?.GetBaseException().Message}",
                        null));
        }).ToArray();

        Task.WhenAll(asks).ContinueWith(t =>
        {
            // ContinueWith on WhenAll over per-task error-trapping completions
            // never faults, so t.Result is always populated.
            var acks = t.Result;
            var allSucceeded = acks.All(a => a.Success);
            var firstError = acks.FirstOrDefault(a => !a.Success)?.Error;
            if (!allSucceeded)
            {
                _logger.LogWarning("Cert broadcast partial/total failure ({Description}): {Error}",
                    description, firstError);
            }
            return new CertTrustResult(allSucceeded, firstError, null);
        }).PipeTo(sender);
    }

    /// <summary>
    /// The site node most recently observed coming Up, awaiting its trusted-cert
    /// reconcile push. A site cluster is two nodes, so at most one peer reconciles
    /// at a time; a single pending slot is sufficient (a second join before the
    /// first export returns simply reconciles the newer node — both then converge).
    /// </summary>
    private Address? _pendingCertReconcileTarget;

    /// <summary>
    /// UA1: a site node came Up. On the singleton this is the trigger to reconcile
    /// cert trust onto that node. Self and non-site members are ignored — self is
    /// already the source of truth, and only site nodes host a CertStoreActor.
    /// </summary>
    private void HandleMemberUp(ClusterEvent.MemberUp evt)
    {
        var member = evt.Member;
        if (!member.HasRole(SiteClusterRole))
            return;
        if (member.Address.Equals(Cluster.Get(Context.System).SelfAddress))
            return;

        Self.Tell(new SiteNodeJoined(member.Address));
    }

    /// <summary>
    /// Reads this node's own trusted-peer store (via the LOCAL CertStoreActor) so
    /// its contents can be pushed to the joined node. The export reply is handled
    /// by <see cref="HandleLocalCertExport"/>, which uses the target captured here.
    /// </summary>
    private void HandleSiteNodeJoined(SiteNodeJoined msg)
    {
        _pendingCertReconcileTarget = msg.Address;
        Context.ActorSelection($"/user/{CertStoreActor.WellKnownName}")
            .Tell(new ExportLocalTrustedCerts());
    }

    /// <summary>
    /// Pushes each locally-trusted certificate to the joined node's CertStoreActor.
    /// Additive union only: removals are NOT reconciled here (there is no central
    /// persistence of cert trust yet — a logged follow-up), and each write is
    /// idempotent (overwrites by thumbprint), so re-pushing an already-trusted cert
    /// is harmless. Fire-and-forget: a failed push heals on the next MemberUp or a
    /// manual re-broadcast.
    /// </summary>
    private void HandleLocalCertExport(LocalCertExport export)
    {
        var target = _pendingCertReconcileTarget;
        _pendingCertReconcileTarget = null;
        if (target is null)
            return;

        if (!export.Success)
        {
            _logger.LogWarning(
                "Cert-trust reconcile: could not read local trusted store for joining node {Address}: {Error}",
                target, export.Error);
            return;
        }

        var basePath = new RootActorPath(target) / "user" / CertStoreActor.WellKnownName;
        foreach (var cert in export.Certs)
        {
            Context.ActorSelection(basePath).Tell(
                new WriteCertToLocalStore(Convert.ToBase64String(cert.DerBytes), cert.Thumbprint));
        }

        _logger.LogInformation(
            "Cert-trust reconcile: pushed {Count} trusted cert(s) to joining node {Address}",
            export.Certs.Count, target);
    }

    // ── DCL connection management ──

    /// <summary>
    /// Tracks the configuration last sent to the DCL for each connection name, keyed by
    /// a hash of the connection's protocol/endpoints/credentials/failover count.
    /// A name whose hash is unchanged is skipped; a name whose config
    /// changed re-issues a <c>CreateConnectionCommand</c> so the DCL adopts the new
    /// configuration instead of keeping a stale connection after a redeployment.
    /// </summary>
    private readonly Dictionary<string, string> _createdConnections = new();

    /// <summary>
    /// Sets up DCL connections from the flattened config. Idempotent on unchanged
    /// configuration, but re-issues the create command when a connection's endpoint,
    /// credentials, backup endpoint, or failover retry count has changed since it was
    /// last sent.
    /// </summary>
    private void EnsureDclConnections(string configJson)
    {
        if (_dclManager == null) return;

        try
        {
            var config = System.Text.Json.JsonSerializer.Deserialize<Commons.Types.Flattening.FlattenedConfiguration>(configJson);
            if (config?.Connections == null) return;

            foreach (var (name, connConfig) in config.Connections)
            {
                EnsureDclConnection(
                    name,
                    connConfig.Protocol,
                    connConfig.ConfigurationJson,
                    connConfig.BackupConfigurationJson,
                    connConfig.FailoverRetryCount);
            }
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Failed to parse flattened config for DCL connections");
        }
    }

    /// <summary>
    /// Hash-guarded DCL connection push shared by the inline
    /// per-instance path (<see cref="EnsureDclConnections(string)"/>) and the
    /// system-wide artifact-deploy path (<see cref="HandleDeployArtifacts"/>).
    /// Unchanged config is a no-op; a changed endpoint/credentials/backup/
    /// failover-count re-issues a <c>CreateConnectionCommand</c> so a system-
    /// wide artifact-deploy makes its data-connection change live immediately
    /// (the artifact-deploy path previously only persisted to SQLite — the
    /// DCL didn't see the change until next instance redeploy or node
    /// restart, contradicting the "site is self-contained after artifact
    /// deployment" intent).
    /// </summary>
    private void EnsureDclConnection(
        string name,
        string protocol,
        string? primaryConfigurationJson,
        string? backupConfigurationJson,
        int failoverRetryCount)
    {
        if (_dclManager == null) return;

        var configHash = ComputeConnectionConfigHashCore(
            protocol, primaryConfigurationJson, backupConfigurationJson, failoverRetryCount);
        if (_createdConnections.TryGetValue(name, out var lastHash) && lastHash == configHash)
            return;

        var primaryDetails = FlattenConnectionConfig(protocol, primaryConfigurationJson);
        var backupDetails = string.IsNullOrEmpty(backupConfigurationJson)
            ? null
            : FlattenConnectionConfig(protocol, backupConfigurationJson);

        _dclManager.Tell(new Commons.Messages.DataConnection.CreateConnectionCommand(
            name, protocol, primaryDetails, backupDetails, failoverRetryCount));

        var changed = _createdConnections.ContainsKey(name);
        _createdConnections[name] = configHash;
        _logger.LogInformation(
            "{Action} DCL connection {Connection} (protocol={Protocol})",
            changed ? "Updated" : "Created", name, protocol);
    }

    /// <summary>
    /// Computes a stable hash over the configuration fields that affect how the DCL
    /// connects, so a changed endpoint/credential/backup/failover count is detected.
    /// </summary>
    private static string ComputeConnectionConfigHash(
        Commons.Types.Flattening.ConnectionConfig connConfig) =>
        ComputeConnectionConfigHashCore(
            connConfig.Protocol,
            connConfig.ConfigurationJson,
            connConfig.BackupConfigurationJson,
            connConfig.FailoverRetryCount);

    /// <summary>
    /// Field-based core so the system-wide artifact-deploy
    /// path (which carries protocol/config-json/backup-json/failover directly
    /// on <see cref="Commons.Messages.Artifacts.DataConnectionArtifact"/>) can
    /// share the same hash + skip-or-resend logic as the inline-config path.
    /// </summary>
    private static string ComputeConnectionConfigHashCore(
        string protocol,
        string? primaryConfigurationJson,
        string? backupConfigurationJson,
        int failoverRetryCount)
    {
        var material = string.Join(
            "",
            protocol,
            primaryConfigurationJson ?? string.Empty,
            backupConfigurationJson ?? string.Empty,
            failoverRetryCount.ToString(System.Globalization.CultureInfo.InvariantCulture));

        var bytes = System.Security.Cryptography.SHA256.HashData(
            System.Text.Encoding.UTF8.GetBytes(material));
        return Convert.ToHexString(bytes);
    }

    private static IDictionary<string, string> FlattenConnectionConfig(string protocol, string? json)
    {
        if (string.IsNullOrEmpty(json))
            return new Dictionary<string, string>();

        if (string.Equals(protocol, "OpcUa", StringComparison.OrdinalIgnoreCase))
        {
            var (config, _) = Commons.Serialization.OpcUaEndpointConfigSerializer.Deserialize(json);
            return Commons.Serialization.OpcUaEndpointConfigSerializer.ToFlatDict(config);
        }

        if (string.Equals(protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
        {
            var config = Commons.Serialization.MxGatewayEndpointConfigSerializer.Deserialize(json);
            return Commons.Serialization.MxGatewayEndpointConfigSerializer.ToFlatDict(config);
        }

        // Fallback: assume legacy flat-dict shape for any future / unknown protocol.
        try
        {
            var dict = new Dictionary<string, string>();
            using var doc = System.Text.Json.JsonDocument.Parse(json);
            foreach (var prop in doc.RootElement.EnumerateObject())
                dict[prop.Name] = prop.Value.ToString();
            return dict;
        }
        catch
        {
            return new Dictionary<string, string>();
        }
    }

    // ── Shared Script Loading ──

    /// <summary>
    /// Reads and compiles all shared scripts on a background task so the
    /// SQLite read and Roslyn compilation never block the singleton's mailbox thread. The
    /// result is piped back as a <see cref="SharedScriptsLoaded"/> message, carrying the
    /// enabled configs to resume staggered Instance Actor creation on the actor thread.
    /// </summary>
    private void LoadSharedScriptsFromStorage(List<DeployedInstance> enabledConfigs)
    {
        Task.Run(async () =>
        {
            var scripts = await _storage.GetAllSharedScriptsAsync();
            var compiled = 0;
            foreach (var script in scripts)
            {
                if (_sharedScriptLibrary.CompileAndRegister(script.Name, script.Code))
                    compiled++;
            }
            return new SharedScriptsLoaded(enabledConfigs, compiled, scripts.Count);
        }).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
                return t.Result;
            _logger.LogError(
                t.Exception?.GetBaseException(), "Failed to load shared scripts from SQLite");
            // A shared-script load failure must not abandon startup — proceed with
            // Instance Actor creation; scripts that need a missing shared script fail
            // at execution time and are recorded to the site event log.
            return new SharedScriptsLoaded(enabledConfigs, 0, 0);
        }).PipeTo(Self);
    }

    // ── Debug View routing ──

    private void RouteDebugViewSubscribe(SubscribeDebugViewRequest request)
    {
        if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            instanceActor.Forward(request);
        }
        else
        {
            // Set InstanceNotFound=true so the caller can distinguish
            // "not deployed on this site" from a deployed-but-empty instance.
            _logger.LogWarning(
                "Debug view subscribe for unknown instance {Instance}", request.InstanceUniqueName);
            Sender.Tell(new DebugViewSnapshot(
                request.InstanceUniqueName, Array.Empty<Commons.Messages.Streaming.AttributeValueChanged>(),
                Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), DateTimeOffset.UtcNow,
                InstanceNotFound: true));
        }
    }

    private void RouteDebugViewUnsubscribe(UnsubscribeDebugViewRequest request)
    {
        if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            instanceActor.Forward(request);
        }
    }

    private void RouteDebugSnapshot(DebugSnapshotRequest request)
    {
        if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            instanceActor.Forward(request);
        }
        else
        {
            // Set InstanceNotFound=true so the caller can distinguish
            // "not deployed on this site" from a deployed-but-empty instance.
            _logger.LogWarning(
                "Debug snapshot for unknown instance {Instance}", request.InstanceUniqueName);
            Sender.Tell(new DebugViewSnapshot(
                request.InstanceUniqueName, Array.Empty<Commons.Messages.Streaming.AttributeValueChanged>(),
                Array.Empty<Commons.Messages.Streaming.AlarmStateChanged>(), DateTimeOffset.UtcNow,
                InstanceNotFound: true));
        }
    }

    // ── Inbound API routing ──

    private void RouteInboundApiCall(RouteToCallRequest request)
    {
        if (_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            // Convert to ScriptCallRequest and Ask the Instance Actor.
            // (ParentExecutionId): carry the inbound request's
            // ExecutionId down as ParentExecutionId so the routed script
            // execution can record its spawner.
            var scriptCall = new ScriptCallRequest(
                request.ScriptName, request.Parameters, 0, request.CorrelationId,
                ParentExecutionId: request.ParentExecutionId);
            var sender = Sender;
            instanceActor.Ask<ScriptCallResult>(scriptCall, TimeSpan.FromSeconds(30))
                .ContinueWith(t =>
                {
                    if (t.IsCompletedSuccessfully)
                    {
                        var result = t.Result;
                        return new RouteToCallResponse(
                            request.CorrelationId, result.Success,
                            // The routed script's return value crosses the Central↔Site
                            // PROCESS boundary inside this response. A script's natural
                            // `return new { ... }` is an anonymous type, which Akka's
                            // cross-process serializer cannot round-trip — the reply is
                            // silently dropped and the caller's Route.To().Call() Ask
                            // times out even though the script succeeded. Project the
                            // value to a plain CLR graph (Dictionary/List/primitive) that
                            // round-trips the wire and reproduces the same JSON shape on
                            // the inbound side (which JsonSerializer-serializes it for the
                            // HTTP body / ReturnDefinition validation).
                            NormalizeRoutedReturnValue(result.ReturnValue),
                            result.ErrorMessage, DateTimeOffset.UtcNow);
                    }
                    return new RouteToCallResponse(
                        request.CorrelationId, false, null,
                        t.Exception?.GetBaseException().Message ?? "Script call timed out",
                        DateTimeOffset.UtcNow);
                }).PipeTo(sender);
        }
        else
        {
            Sender.Tell(new RouteToCallResponse(
                request.CorrelationId, false, null,
                $"Instance '{request.InstanceUniqueName}' not found on this site.",
                DateTimeOffset.UtcNow));
        }
    }

    /// <summary>
    /// Projects a routed script's return value to a cross-process-serializable plain
    /// CLR graph (<see cref="Dictionary{TKey,TValue}"/> / <see cref="List{T}"/> /
    /// string / long / double / bool / null) via a JSON round-trip. A script's natural
    /// <c>return new { ... }</c> is a compiler-generated anonymous type that Akka's
    /// cross-process serializer cannot reconstruct on the receiving node, so the
    /// <see cref="RouteToCallResponse"/> reply is silently dropped and the caller's
    /// Ask times out. The projected graph round-trips the wire and re-serializes to the
    /// same JSON shape the inbound API expects. Returns <paramref name="value"/>
    /// unchanged on a (non-expected) serialization failure so a quirky value still has a
    /// chance to deliver rather than being forced to null.
    /// </summary>
    private object? NormalizeRoutedReturnValue(object? value)
    {
        if (value is null)
        {
            return null;
        }

        try
        {
            var json = System.Text.Json.JsonSerializer.Serialize(value);
            using var doc = System.Text.Json.JsonDocument.Parse(json);
            return FromJsonElement(doc.RootElement);
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex,
                "Failed to normalize routed script return value of type {Type} for transport; sending as-is",
                value.GetType().Name);
            return value;
        }
    }

    /// <summary>
    /// Converts a <see cref="System.Text.Json.JsonElement"/> to a plain CLR value
    /// (string / long / double / bool / null, or nested Dictionary / List) — never a
    /// <see cref="System.Text.Json.JsonElement"/> (which is itself not cross-process
    /// serializable). Companion to <see cref="NormalizeRoutedReturnValue"/>.
    /// </summary>
    private static object? FromJsonElement(System.Text.Json.JsonElement e) => e.ValueKind switch
    {
        System.Text.Json.JsonValueKind.Object =>
            e.EnumerateObject().ToDictionary(p => p.Name, p => FromJsonElement(p.Value)),
        System.Text.Json.JsonValueKind.Array =>
            e.EnumerateArray().Select(FromJsonElement).ToList(),
        System.Text.Json.JsonValueKind.String => e.GetString(),
        System.Text.Json.JsonValueKind.Number => e.TryGetInt64(out var l) ? l : e.GetDouble(),
        System.Text.Json.JsonValueKind.True => true,
        System.Text.Json.JsonValueKind.False => false,
        _ => null,
    };

    /// <summary>
    /// Reads attribute values from a deployed instance for a Route.To().GetAttribute(s)
    /// call (or a central Test Run bound to the instance). Asks the Instance Actor
    /// per attribute and combines the results.
    /// </summary>
    private void RouteInboundApiGetAttributes(RouteToGetAttributesRequest request)
    {
        if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            Sender.Tell(new RouteToGetAttributesResponse(
                request.CorrelationId, new Dictionary<string, object?>(), false,
                $"Instance '{request.InstanceUniqueName}' not found on this site.",
                DateTimeOffset.UtcNow));
            return;
        }

        var sender = Sender;
        var names = request.AttributeNames;
        var asks = names
            .Select(name => instanceActor.Ask<GetAttributeResponse>(
                new GetAttributeRequest(
                    request.CorrelationId, request.InstanceUniqueName, name, DateTimeOffset.UtcNow),
                TimeSpan.FromSeconds(30)))
            .ToArray();

        Task.WhenAll(asks).ContinueWith(t =>
        {
            if (t.IsCompletedSuccessfully)
            {
                var values = new Dictionary<string, object?>();
                for (var i = 0; i < names.Count; i++)
                    // Each attribute value crosses the Central↔Site PROCESS boundary inside
                    // this response. For a List-typed (static or coerced) attribute the value
                    // is a concrete generic List<T> — a non-primitive shape Akka's cross-process
                    // serializer cannot reliably round-trip, which would silently drop the reply
                    // and hang the caller's Route.To().GetAttributes() Ask (same risk class as
                    // RouteInboundApiCall / RouteInboundApiWaitForAttribute). Project each value
                    // through the same normalizer so the wire carries a plain CLR graph
                    // (Dictionary/List/primitive). Keys are preserved; scalars/strings/null pass
                    // through unchanged.
                    values[names[i]] = t.Result[i].Found
                        ? NormalizeRoutedReturnValue(t.Result[i].Value)
                        : null;
                return new RouteToGetAttributesResponse(
                    request.CorrelationId, values, true, null, DateTimeOffset.UtcNow);
            }
            return new RouteToGetAttributesResponse(
                request.CorrelationId, new Dictionary<string, object?>(), false,
                t.Exception?.GetBaseException().Message ?? "Attribute read timed out",
                DateTimeOffset.UtcNow);
        }).PipeTo(sender);
    }

    /// <summary>
    /// Spec §6 (WD-2b): unpacks a routed <see cref="RouteToWaitForAttributeRequest"/>
    /// (inbound-API <c>Route.To().WaitForAttribute()</c>) into the deployed
    /// Instance Actor's site-local <see cref="WaitForAttributeRequest"/> and relays
    /// the result back. Value-equality across the wire — the predicate is null (a
    /// cross-process lambda cannot be routed), but <c>RequireGoodQuality</c> is honored
    /// from <see cref="RouteToWaitForAttributeRequest.RequireGoodQuality"/>. The Ask is
    /// bounded by the wait timeout plus slack (NOT a fixed 30s), since the wait
    /// legitimately blocks for up to <see cref="RouteToWaitForAttributeRequest.Timeout"/>.
    /// </summary>
    private void RouteInboundApiWaitForAttribute(RouteToWaitForAttributeRequest request)
    {
        if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            Sender.Tell(new RouteToWaitForAttributeResponse(
                request.CorrelationId, false, null, null, false,
                false, $"Instance '{request.InstanceUniqueName}' not found on this site.",
                DateTimeOffset.UtcNow));
            return;
        }

        var sender = Sender;
        // Routed waits are value-equality (predicate null — a cross-process lambda
        // cannot be routed); RequireGoodQuality is honored from the request.
        var inner = new WaitForAttributeRequest(
            request.CorrelationId, request.InstanceUniqueName, request.AttributeName,
            request.TargetValueEncoded, null, request.Timeout, DateTimeOffset.UtcNow,
            request.RequireGoodQuality);

        // Ask bounded by the WAIT timeout + slack — NOT a fixed 30s (the wait legitimately blocks up to request.Timeout).
        instanceActor.Ask<WaitForAttributeResponse>(inner, request.Timeout + TimeSpan.FromSeconds(5))
            .ContinueWith(t => t.IsCompletedSuccessfully
                // The matched value crosses the Central↔Site PROCESS boundary inside this
                // response. For a List-typed attribute the Instance Actor's matched
                // `WaitForAttributeResponse.Value` is a concrete generic List<T>
                // (List<int>/List<double>/List<DateTime>/… built by TryCoerceListValue;
                // see InstanceActor.HandleTagValueUpdate / ResolveMatchedWaiters) — a
                // non-primitive shape that Akka's cross-process serializer cannot reliably
                // round-trip, which would silently drop the reply and hang the caller's
                // Ask. Project it through the same normalizer RouteInboundApiCall uses so
                // the wire carries a plain CLR graph (List/primitive). Scalars/strings/null
                // pass through unchanged.
                ? new RouteToWaitForAttributeResponse(
                    request.CorrelationId, t.Result.Matched, NormalizeRoutedReturnValue(t.Result.Value),
                    t.Result.Quality, t.Result.TimedOut,
                    true, null, DateTimeOffset.UtcNow)
                : new RouteToWaitForAttributeResponse(
                    request.CorrelationId, false, null, null, false,
                    false, t.Exception?.GetBaseException().Message ?? "Attribute wait timed out",
                    DateTimeOffset.UtcNow))
            .PipeTo(sender);
    }

    /// <summary>
    /// Writes attribute values on a deployed instance for a Route.To().SetAttribute(s)
    /// call (or a central Test Run bound to the instance). Each write is Ask'd to the
    /// Instance Actor, which routes data-sourced attributes through the DCL and static
    /// attributes to a persisted override. The response reflects the real per-attribute
    /// outcome (a non-existent attribute or a failed device write reports failure),
    /// rather than an unconditional optimistic ack.
    /// </summary>
    private void RouteInboundApiSetAttributes(RouteToSetAttributesRequest request)
    {
        if (!_instanceActors.TryGetValue(request.InstanceUniqueName, out var instanceActor))
        {
            Sender.Tell(new RouteToSetAttributesResponse(
                request.CorrelationId, false,
                $"Instance '{request.InstanceUniqueName}' not found on this site.",
                DateTimeOffset.UtcNow));
            return;
        }

        var sender = Sender;
        var correlationId = request.CorrelationId;
        var asks = request.AttributeValues
            .Select(kvp => instanceActor.Ask<SetStaticAttributeResponse>(
                new SetStaticAttributeCommand(
                    correlationId, request.InstanceUniqueName, kvp.Key, kvp.Value, DateTimeOffset.UtcNow,
                    // (ParentExecutionId tag-cascade): the routed
                    // write's originating execution is the inbound API request
                    // that issued Route.To(...).SetAttributes(...) — already
                    // carried on the request as ParentExecutionId. Stamping it
                    // here lets an alarm this write trips chain its on-trigger
                    // script run under the inbound execution.
                    SourceExecutionId: request.ParentExecutionId),
                TimeSpan.FromSeconds(30)))
            .ToArray();

        Task.WhenAll(asks).ContinueWith(t =>
        {
            if (!t.IsCompletedSuccessfully)
                return new RouteToSetAttributesResponse(
                    correlationId, false,
                    t.Exception?.GetBaseException().Message ?? "Attribute write timed out",
                    DateTimeOffset.UtcNow);

            var failures = t.Result
                .Where(r => !r.Success)
                .Select(r => $"{r.AttributeName}: {r.ErrorMessage}")
                .ToArray();

            return failures.Length == 0
                ? new RouteToSetAttributesResponse(correlationId, true, null, DateTimeOffset.UtcNow)
                : new RouteToSetAttributesResponse(
                    correlationId, false, string.Join("; ", failures), DateTimeOffset.UtcNow);
        }).PipeTo(sender);
    }

    /// <summary>
    /// Handles system-wide artifact deployment (shared scripts, external systems, etc.).
    /// Persists artifacts to SiteStorageService and recompiles shared scripts.
    /// </summary>
    private void HandleDeployArtifacts(DeployArtifactsCommand command)
    {
        _logger.LogInformation(
            "Deploying system artifacts, deploymentId={DeploymentId}", command.DeploymentId);

        var sender = Sender;
        // Capture Self before entering Task.Run: the Self/Sender/Context properties
        // are backed by the ambient ActorCell, which is null on a thread-pool thread,
        // so reading Self *inside* the lambda throws "no active ActorContext". The
        // data-connections branch below dispatches via this captured ref.
        var self = Self;

        Task.Run(async () =>
        {
            try
            {
                // Each non-null artifact list is the COMPLETE system-wide set
                // (ArtifactDeploymentService ships GetAll* snapshots), so the apply is
                // upsert-then-reconcile: store every incoming artifact, then delete any
                // stored row absent from the set — that artifact was deleted centrally.
                // Upsert-only apply used to leave such rows orphaned on the site forever
                // (a centrally-deleted external system stayed callable from site scripts).
                // A null list means "field not shipped" and touches nothing.

                // Store shared scripts and recompile
                if (command.SharedScripts != null)
                {
                    foreach (var script in command.SharedScripts)
                    {
                        await _storage.StoreSharedScriptAsync(script.Name, script.Code,
                            script.ParameterDefinitions, script.ReturnDefinition);

                        // Shared scripts recompiled on update
                        _sharedScriptLibrary.CompileAndRegister(script.Name, script.Code);
                    }

                    var removedScripts = await _storage.DeleteSharedScriptsExceptAsync(
                        command.SharedScripts.Select(s => s.Name).ToList());
                    foreach (var name in removedScripts)
                        _sharedScriptLibrary.Remove(name);
                }

                // Store external system definitions
                if (command.ExternalSystems != null)
                {
                    foreach (var es in command.ExternalSystems)
                    {
                        await _storage.StoreExternalSystemAsync(es.Name, es.EndpointUrl,
                            es.AuthType, es.AuthConfiguration, es.MethodDefinitionsJson, es.TimeoutSeconds);
                    }

                    await _storage.DeleteExternalSystemsExceptAsync(
                        command.ExternalSystems.Select(es => es.Name).ToList());

                    // WP2.6a: drop the shared read cache so the next script call sees
                    // the freshly-applied definitions/method lists instead of a stale
                    // snapshot loaded before this deploy.
                    _externalSystemCache?.InvalidateAll();
                }

                // Store database connection definitions
                if (command.DatabaseConnections != null)
                {
                    foreach (var db in command.DatabaseConnections)
                    {
                        await _storage.StoreDatabaseConnectionAsync(db.Name, db.ConnectionString,
                            db.MaxRetries, db.RetryDelay);
                    }

                    await _storage.DeleteDatabaseConnectionsExceptAsync(
                        command.DatabaseConnections.Select(db => db.Name).ToList());
                }

                // Notification lists and SMTP
                // configuration are central-only — sites store-and-forward notifications
                // to central and never deliver over SMTP. Central no longer ships these
                // (the DeployArtifactsCommand fields stay for additive compatibility but
                // are always null), so the site neither persists them nor reads them.
                // Purge any rows a prior (pre-fix) build may have written — including the
                // plaintext SMTP password — so existing exposure is cleared, not just
                // future writes. Purge is idempotent and runs on every artifact apply.
                await _storage.PurgeCentralOnlyNotificationConfigAsync();

                // Store data connection definitions (OPC UA endpoints, etc.)
                if (command.DataConnections != null)
                {
                    foreach (var dc in command.DataConnections)
                    {
                        await _storage.StoreDataConnectionDefinitionAsync(
                            dc.Name, dc.Protocol, dc.PrimaryConfigurationJson,
                            dc.BackupConfigurationJson, dc.FailoverRetryCount);
                    }

                    var removedConnections = await _storage.DeleteDataConnectionDefinitionsExceptAsync(
                        command.DataConnections.Select(dc => dc.Name).ToList());

                    // After the SQLite store, dispatch an
                    // internal message back to the actor thread so the DCL
                    // push runs through EnsureDclConnection — keeping the
                    // _createdConnections hash cache mutation actor-thread-
                    // confined while still making the change live immediately
                    // (previously the change landed in SQLite but the DCL
                    // kept using the stale connection until next instance
                    // redeploy or node restart, contradicting "site is
                    // self-contained after artifact deployment"). The
                    // helper's hash cache skips unchanged definitions, so
                    // the push is idempotent for re-deploys of the same
                    // artifact bundle. Reconciled removals ride the same
                    // message so the live DCL connection actor is stopped
                    // on the actor thread alongside the hash-cache eviction.
                    self.Tell(new ApplyArtifactDataConnectionsToDcl(
                        command.DataConnections, removedConnections));
                }

                // SMTP configuration is
                // central-only and is never stored on a site (see the purge above).

                // Replicate artifacts to standby node

                return new ArtifactDeploymentResponse(
                    command.DeploymentId, "", true, null, DateTimeOffset.UtcNow);
            }
            catch (Exception ex)
            {
                return new ArtifactDeploymentResponse(
                    command.DeploymentId, "", false, ex.Message, DateTimeOffset.UtcNow);
            }
        }).PipeTo(sender);
    }

    /// <summary>
    /// Creates a child Instance Actor with the given name and configuration JSON.
    /// </summary>
    /// <param name="instanceName">The unique name of the instance to create an actor for.</param>
    /// <param name="configJson">The JSON-serialized flattened configuration for the instance.</param>
    internal void CreateInstanceActor(string instanceName, string configJson)
    {
        if (_instanceActors.ContainsKey(instanceName))
        {
            _logger.LogWarning("Instance Actor {Instance} already exists, skipping creation", instanceName);
            return;
        }

        // Reuse the shared, host-configured logger factory
        // instead of allocating (and leaking) a fresh LoggerFactory per instance.
        var props = Props.Create(() => new InstanceActor(
            instanceName,
            configJson,
            _storage,
            _compilationService,
            _sharedScriptLibrary,
            _streamManager,
            _options,
            _loggerFactory.CreateLogger<InstanceActor>(),
            _dclManager,
            _healthCollector,
            _serviceProvider));

        var actorRef = Context.ActorOf(props, instanceName);
        _instanceActors[instanceName] = actorRef;
        // Watch every Instance Actor so an unexpected termination (init failure or
        // crash) delivers a Terminated signal that evicts the dead ref from routing
        // (S6). Redeploy already double-watches; a double Watch is idempotent in
        // Akka.NET (a single Terminated is delivered).
        Context.Watch(actorRef);

        _logger.LogDebug("Created Instance Actor for {Instance}", instanceName);
    }

    /// <summary>
    /// Actor-thread handler that pushes artifact-deploy data
    /// connection definitions to the DCL via the shared
    /// <see cref="EnsureDclConnection"/> helper. Dispatched from
    /// <see cref="HandleDeployArtifacts"/>'s off-thread Task so the
    /// <see cref="_createdConnections"/> hash-cache mutation stays
    /// actor-thread-confined.
    /// </summary>
    private void HandleApplyArtifactDataConnectionsToDcl(ApplyArtifactDataConnectionsToDcl msg)
    {
        foreach (var dc in msg.DataConnections)
        {
            EnsureDclConnection(
                dc.Name,
                dc.Protocol,
                dc.PrimaryConfigurationJson,
                dc.BackupConfigurationJson,
                dc.FailoverRetryCount);
        }

        // Reconciled removals: the definition row is already gone from SQLite; evict
        // the hash cache (so a later re-add with the same name recreates cleanly) and
        // stop the live DCL connection actor. A connection still referenced by a
        // deployed instance's config no longer exists centrally either — its attributes
        // go bad quality, the standard disconnected signal, until the instance is
        // redeployed against current central config.
        foreach (var name in msg.RemovedConnectionNames)
        {
            _createdConnections.Remove(name);
            _dclManager?.Tell(new DataConnectionLayer.Actors.RemoveConnectionCommand(name));
            _logger.LogWarning(
                "Artifact reconcile removed data connection '{Name}' (deleted centrally); " +
                "its DCL connection actor has been stopped", name);
        }
    }

    /// <summary>
    /// Gets the count of active Instance Actors (for testing/diagnostics).
    /// </summary>
    internal int InstanceActorCount => _instanceActors.Count;

    /// <summary>
    /// Updates the health collector with current instance counts, and (WP3.1) grows the
    /// script-execution pool to match.
    /// Total deployed = _deployedInstanceNames.Count, enabled = running actors, disabled = difference.
    /// </summary>
    private void UpdateInstanceCounts()
    {
        _healthCollector?.SetInstanceCounts(
            deployed: _deployedInstanceNames.Count,
            enabled: _instanceActors.Count,
            disabled: _deployedInstanceNames.Count - _instanceActors.Count);

        // WP3.1: the blocking script pool scales with the number of running instances rather
        // than sitting at a fixed 8 forever. This is the single call site because it already
        // runs on every deploy / undeploy / enable / disable and once per staggered startup
        // batch. Growth is idempotent and one-way — see ScriptExecutionScheduler.EnsureCapacity.
        try
        {
            var target = ScriptExecutionScheduler.ComputeTargetThreads(_instanceActors.Count, _options);
            (_scriptScheduler ?? ScriptExecutionScheduler.Shared(_options)).EnsureCapacity(target);
        }
        catch (Exception ex)
        {
            // Pool sizing is an optimisation, never a correctness requirement: a failure here
            // must not fail the deploy/enable/disable that triggered it.
            _logger.LogWarning(ex, "Failed to resize the script-execution pool; continuing at its current size.");
        }
    }

    // ── Internal messages ──

    /// <summary>
    /// UA1: a site cluster node has come Up and should receive a cert-trust
    /// reconcile push. Derived from <see cref="ClusterEvent.MemberUp"/> (self and
    /// non-site members already filtered out) so the reconcile flow is unit-testable
    /// without constructing a real cluster membership event.
    /// </summary>
    internal sealed record SiteNodeJoined(Address Address);

    internal record StartupConfigsLoaded(List<DeployedInstance> Configs, string? Error);

    /// <summary>
    /// Internal singleton signal that re-attempts the startup deployed-config load
    /// after a transient SQLite failure (S5). Delivered by a single-shot Akka timer.
    /// </summary>
    internal sealed record RetryStartupLoad
    {
        /// <summary>The shared singleton instance.</summary>
        public static readonly RetryStartupLoad Instance = new();
        private RetryStartupLoad() { }
    }

    /// <summary>
    /// Internal message piped back once shared scripts have been compiled off-thread
    /// Carries the enabled configs so staggered Instance Actor
    /// creation resumes on the actor thread.
    /// </summary>
    internal record SharedScriptsLoaded(
        List<DeployedInstance> EnabledConfigs, int CompiledCount, int TotalCount);

    /// <summary>
    /// WP3.1 warm-then-gate: piped back to self once a deployment's scripts have been
    /// compiled off the actor thread into the process-wide compile cache. Carries the exact
    /// command instance it warmed so a result superseded mid-warm is recognised by reference
    /// and dropped.
    /// </summary>
    internal sealed record DeployCompileWarmed(DeployInstanceCommand Command);

    /// <summary>
    /// WP3.1: piped back to self once a staggered startup batch's compiles have been
    /// pre-warmed, carrying the original batch message so actor creation resumes unchanged.
    /// </summary>
    internal sealed record BatchCompileWarmed(StartNextBatch Batch);

    /// <summary>
    /// A lifecycle command queued because its instance's deploy was mid-compile-warm, with
    /// the sender to answer once it is re-dispatched.
    /// </summary>
    internal sealed record BufferedInstanceCommand(object Command, IActorRef Sender);

    /// <summary>
    /// The in-flight state of one instance's deploy compile warm: the deploy that will be
    /// applied when the warm lands (replaceable last-write-wins while nothing is queued behind
    /// it), the deployer to answer, and the ordered commands queued during the warm.
    /// </summary>
    internal sealed class DeployWarmState(DeployInstanceCommand command, IActorRef replyTo)
    {
        /// <summary>The deploy to apply when the warm completes.</summary>
        public DeployInstanceCommand Command { get; set; } = command;

        /// <summary>The deployer awaiting this deploy's <see cref="DeploymentStatusResponse"/>.</summary>
        public IActorRef ReplyTo { get; set; } = replyTo;

        /// <summary>Commands for this instance that arrived during the warm, in arrival order.</summary>
        public List<BufferedInstanceCommand> Buffered { get; } = [];
    }

    internal record StartNextBatch(BatchState State);
    internal record BatchState(List<DeployedInstance> Configs, int NextIndex);
    internal record EnableResult(
        EnableInstanceCommand Command, DeployedInstance? Config, string? Error, IActorRef OriginalSender);
    internal record DeployPersistenceResult(
        string DeploymentId, string InstanceName, bool Success, string? Error,
        IActorRef OriginalSender, bool IsRedeploy);

    /// <summary>
    /// A redeployment command buffered until the previous Instance Actor terminates.
    /// </summary>
    internal record PendingRedeploy(DeployInstanceCommand Command, IActorRef OriginalSender);

    /// <summary>
    /// Join state for a deployment awaiting confirmation. Deployment <c>Success</c> is
    /// reported only once BOTH signals land: <see cref="Persisted"/> (the config committed
    /// to SQLite) AND <see cref="Initialized"/> (the Instance Actor finished PreStart and
    /// sent <see cref="Messages.InstanceActorInitialized"/>). Persistence can commit before
    /// the actor's asynchronous init has run or failed, so persistence alone is not enough
    /// to declare success (S6). An actor that dies during init never sets
    /// <see cref="Initialized"/>; the S6 Terminated fallback fails that deployment instead.
    /// <see cref="IsRedeploy"/> gates rollback so a failed init rolls back the
    /// deployed-instance set and the persisted row only for a genuinely new deployment.
    /// </summary>
    internal sealed class InFlightDeploy
    {
        /// <summary>The deployment id echoed back in the status response.</summary>
        public required string DeploymentId { get; init; }
        /// <summary>The deployer to answer exactly once.</summary>
        public required IActorRef ReplyTo { get; init; }
        /// <summary>Whether this is a redeploy (an update) rather than a new deployment.</summary>
        public required bool IsRedeploy { get; init; }
        /// <summary>Set when the config has committed to SQLite.</summary>
        public bool Persisted { get; set; }
        /// <summary>Set when the Instance Actor has finished PreStart successfully.</summary>
        public bool Initialized { get; set; }
    }

    /// <summary>
    /// Notify-and-fetch: piped back to self when the deployment's flattened
    /// config has been fetched from central. Carries the original central sender so the
    /// reused apply path replies to it.
    /// </summary>
    private sealed record RefreshFetched(RefreshDeploymentCommand Cmd, string ConfigJson, IActorRef ReplyTo);

    /// <summary>
    /// Notify-and-fetch: piped back to self when the config fetch failed.
    /// Carries the original central sender so a Failed status is reported to it.
    /// </summary>
    private sealed record RefreshFetchFailed(RefreshDeploymentCommand Cmd, string Error, IActorRef ReplyTo);

    /// <summary>
    /// Internal message dispatched from
    /// <see cref="HandleDeployArtifacts"/>'s off-thread persistence task back
    /// onto the actor thread, so the DCL push (and its hash-cache mutation)
    /// runs through <see cref="EnsureDclConnection"/> without crossing
    /// thread-confinement boundaries. <paramref name="RemovedConnectionNames"/>
    /// carries the reconcile-deleted definitions so the hash-cache eviction and
    /// live DCL connection stop also happen actor-thread-confined.
    /// </summary>
    internal record ApplyArtifactDataConnectionsToDcl(
        IReadOnlyList<Commons.Messages.Artifacts.DataConnectionArtifact> DataConnections,
        IReadOnlyList<string> RemovedConnectionNames);
}
