using System.Buffers.Binary;
using System.Security.Cryptography;
using System.Text.Json;
using System.Text.Json.Serialization;
using Akka.Actor;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Notifications;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;

namespace ZB.MOM.WW.ScadaBridge.ManagementService;

/// <summary>
/// Central actor that handles all management commands from the CLI (via the HTTP /management endpoint).
/// Receives ManagementEnvelope messages, authorizes based on roles, then delegates to
/// the appropriate service or repository using scoped DI.
/// </summary>
public class ManagementActor : ReceiveActor
{
    private readonly IServiceProvider _serviceProvider;
    private readonly ILogger<ManagementActor> _logger;

    /// <summary>Initializes a new instance of <see cref="ManagementActor"/> and registers the message handler.</summary>
    /// <param name="serviceProvider">DI service provider for scoped repository and service access.</param>
    /// <param name="logger">Logger instance.</param>
    public ManagementActor(IServiceProvider serviceProvider, ILogger<ManagementActor> logger)
    {
        _serviceProvider = serviceProvider;
        _logger = logger;
        Receive<ManagementEnvelope>(HandleEnvelope);
    }

    /// <summary>
    /// Builds the supervision strategy for <see cref="ManagementActor"/>. Per the project's
    /// Akka.NET conventions, coordinator-style actors use a Resume-based strategy so a faulted
    /// child preserves its state rather than restarting. <see cref="ManagementActor"/> spawns
    /// no children today, but declaring the strategy explicitly matches the convention and
    /// makes the contract correct ahead of any future worker actors.
    /// </summary>
    /// <returns>A one-for-one Resume strategy with no retry limit.</returns>
    public static SupervisorStrategy CreateSupervisorStrategy() =>
        new OneForOneStrategy(
            maxNrOfRetries: -1,
            withinTimeRange: System.Threading.Timeout.InfiniteTimeSpan,
            decider: Decider.From(_ => Directive.Resume));

    /// <inheritdoc />
    protected override SupervisorStrategy SupervisorStrategy() => CreateSupervisorStrategy();

    /// <summary>
    /// Serializer settings for command results. <see cref="ReferenceHandler.IgnoreCycles"/>
    /// keeps EF-backed entity graphs with bidirectional navigation properties from throwing;
    /// camelCase matches what the CLI / HTTP layer expect.
    /// </summary>
    private static readonly JsonSerializerOptions ResultSerializerOptions = new()
    {
        ReferenceHandler = ReferenceHandler.IgnoreCycles,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
    };

    /// <summary>
    /// Serializes a command result to JSON using <see cref="System.Text.Json"/> — the same
    /// serializer the HTTP endpoint uses — with cycle-safe settings.
    /// </summary>
    /// <param name="result">The command result object to serialize, or null.</param>
    /// <returns>The JSON string representation of <paramref name="result"/>, or <c>"null"</c> when null.</returns>
    public static string SerializeResult(object? result) =>
        JsonSerializer.Serialize(result, ResultSerializerOptions);

    private void HandleEnvelope(ManagementEnvelope envelope)
    {
        var sender = Sender;
        var correlationId = envelope.CorrelationId;
        var user = envelope.User;

        // Check authorization. A command may require ANY of a set of roles
        // (any-of): the caller is authorized when they hold at least one.
        var requiredRoles = GetRequiredRoles(envelope.Command);
        if (requiredRoles is not null
            && !requiredRoles.Any(r => user.Roles.Contains(r, StringComparer.OrdinalIgnoreCase)))
        {
            sender.Tell(new ManagementUnauthorized(correlationId,
                $"One of roles [{string.Join(", ", requiredRoles)}] required for {envelope.Command.GetType().Name}"));
            return;
        }

        // Process the command and pipe the mapped result back to the captured sender.
        // PipeTo (rather than Task.Run + Tell-from-continuation) is the project's Akka.NET
        // convention: faults are mapped in the failure continuation, no actor state is
        // captured in the closure, and synchronous faults in command setup are still mapped.
        ProcessCommand(envelope, user)
            .PipeTo(sender,
                success: result => result,
                failure: ex => MapFault(ex, correlationId, envelope.Command));
    }

    /// <summary>
    /// Runs a command on a scoped service provider and maps the result to a management
    /// response message. Returns a faulted task on error so the PipeTo failure
    /// continuation maps it uniformly.
    /// </summary>
    private async Task<object> ProcessCommand(ManagementEnvelope envelope, AuthenticatedUser user)
    {
        using var scope = _serviceProvider.CreateScope();
        var result = await DispatchCommand(scope.ServiceProvider, envelope.Command, user);
        return new ManagementSuccess(envelope.CorrelationId, SerializeResult(result));
    }

    /// <summary>
    /// Maps an exception from command processing to the appropriate management response.
    /// </summary>
    private object MapFault(Exception ex, string correlationId, object command)
    {
        // PipeTo wraps continuation exceptions; unwrap to find the real cause.
        var cause = ex is AggregateException agg ? agg.Flatten().InnerException ?? ex : ex;

        if (cause is SiteScopeViolationException scope)
            return new ManagementUnauthorized(correlationId, scope.Message);

        _logger.LogError(cause, "Management command {Command} failed (CorrelationId={CorrelationId})",
            command.GetType().Name, correlationId);

        // Curated handler failures (ManagementCommandException) carry a message
        // that is safe to surface to the caller. Any other exception is an
        // unanticipated fault whose raw .Message can disclose internal detail
        // (server/database names, constraint names, stack context) — return a
        // generic message and let the operator correlate via the server log
        // using the correlation ID.
        var clientMessage = cause is ManagementCommandException
            ? cause.Message
            : $"An internal error occurred (CorrelationId={correlationId}).";
        return new ManagementError(correlationId, clientMessage, "COMMAND_FAILED");
    }

    // Static readonly role sets for the authorization gate. Single-role arms
    // stay one-element arrays so every arm has the uniform any-of shape.
    private static readonly string[] AdminOnly = [Roles.Administrator];
    private static readonly string[] DesignerOnly = [Roles.Designer];
    private static readonly string[] DeployerOnly = [Roles.Deployer];
    private static readonly string[] OperatorOnly = [Roles.Operator];
    private static readonly string[] VerifierOnly = [Roles.Verifier];

    // arch-review C6: area management is reconciled to any-of [Designer, Deployer].
    // Areas are authored on both real surfaces — the Designer template/topology
    // tooling AND the Central UI Deployment Topology workflow (Topology.razor is
    // RequireDeployment) — so a pure Deployer must be able to create/update/delete
    // them. The three requirements docs' historical "Administrator" assignment was
    // stale; Administrator is not implicitly included here, matching the actor's
    // any-of treatment of every other Designer/Deployer command.
    private static readonly string[] AreaManagers = [Roles.Designer, Roles.Deployer];

    // arch-review UA1: the Secured Writes history table exposes
    // process-sensitive tag values, so reading it is restricted to the
    // two-person Secured Write roles plus Administrator (any-of) — NOT any
    // authenticated user like the other list/query commands.
    private static readonly string[] SecuredWriteReaders =
        [Roles.Operator, Roles.Verifier, Roles.Administrator];

    /// <summary>
    /// Returns the set of roles that satisfy the authorization gate for
    /// <paramref name="command"/> (any-of semantics), or <c>null</c> when the
    /// command is available to any authenticated user. Internal so the
    /// authorization matrix can be asserted directly in tests.
    /// </summary>
    /// <param name="command">The management command to authorize.</param>
    /// <returns>The set of roles that satisfy the authorization gate, or <c>null</c> if any authenticated user may issue the command.</returns>
    internal static IReadOnlyList<string>? GetRequiredRoles(object command) => command switch
    {
        // Administrator operations
        CreateSiteCommand or UpdateSiteCommand or DeleteSiteCommand
            or ListRoleMappingsCommand or CreateRoleMappingCommand
            or UpdateRoleMappingCommand or DeleteRoleMappingCommand
            or ListApiKeysCommand or CreateApiKeyCommand or DeleteApiKeyCommand
            or UpdateApiKeyCommand or SetApiKeyMethodsCommand
            or ListScopeRulesCommand or AddScopeRuleCommand or DeleteScopeRuleCommand
            // QueryAuditLogCommand: legacy Action/EntityType filter on the
            // configuration audit log. Gated Admin-only so this older path is
            // never looser than the keyset-paged `/api/audit/query` endpoint
            // (which requires OperationalAuditRoles). New audit consumers
            // should use the REST endpoint; this command is retained for
            // backward compatibility with the CentralUI Configuration Audit
            // Log page.
            or QueryAuditLogCommand
            // SMTP/SMS provider-config updates rotate stored secrets
            // (SMTP Credentials, Twilio AuthToken). Both the /notifications/smtp and
            // /notifications/sms Central UI pages enforce RequireAdmin, so the actor
            // gate must match — otherwise a Designer blocked in the UI could still
            // rotate a production credential via the CLI/Management API.
            or UpdateSmtpConfigCommand
            or UpdateSmsConfigCommand
            // Server-certificate trust (arch-review C4). These mutate the site's
            // trusted-peer PKI store — Admin, matching the Admin-gated UI cert
            // page. Site-side reconcile semantics are owned by the site
            // CertStoreActor (plan 03); this handler only relays.
            or TrustServerCertCommand or RemoveServerCertCommand or ListServerCertsCommand => AdminOnly,

        // Area management — any-of [Designer, Deployer] (arch-review C6).
        // Exposed both in the Designer tooling and inside the Central UI
        // Deployment Topology workflow (RequireDeployment), so both roles qualify.
        CreateAreaCommand or UpdateAreaCommand or MoveAreaCommand
            or DeleteAreaCommand => AreaManagers,

        // Designer operations
        CreateTemplateCommand or UpdateTemplateCommand or DeleteTemplateCommand
            or ValidateTemplateCommand
            or CreateExternalSystemCommand or UpdateExternalSystemCommand
            or DeleteExternalSystemCommand
            or CreateExternalSystemMethodCommand or UpdateExternalSystemMethodCommand
            or DeleteExternalSystemMethodCommand
            or CreateNotificationListCommand or UpdateNotificationListCommand
            or DeleteNotificationListCommand
            or CreateDataConnectionCommand or UpdateDataConnectionCommand
            or DeleteDataConnectionCommand or MoveDataConnectionCommand
            or AddTemplateAttributeCommand or UpdateTemplateAttributeCommand or DeleteTemplateAttributeCommand
            or AddTemplateAlarmCommand or UpdateTemplateAlarmCommand or DeleteTemplateAlarmCommand
            or AddTemplateNativeAlarmSourceCommand or UpdateTemplateNativeAlarmSourceCommand or DeleteTemplateNativeAlarmSourceCommand
            or AddTemplateScriptCommand or UpdateTemplateScriptCommand or DeleteTemplateScriptCommand
            or AddTemplateCompositionCommand or DeleteTemplateCompositionCommand
            or ResyncInheritedMembersCommand
            or CreateSharedScriptCommand or UpdateSharedScriptCommand or DeleteSharedScriptCommand
            or CreateSharedSchemaCommand or UpdateSharedSchemaCommand or DeleteSharedSchemaCommand
            or CreateDatabaseConnectionDefCommand or UpdateDatabaseConnectionDefCommand or DeleteDatabaseConnectionDefCommand
            or CreateApiMethodCommand or UpdateApiMethodCommand or DeleteApiMethodCommand
            or CreateTemplateFolderCommand or RenameTemplateFolderCommand
            or MoveTemplateFolderCommand or ReorderTemplateFolderCommand or DeleteTemplateFolderCommand
            or MoveTemplateToFolderCommand
            // OPC UA / MxGateway design-time remote queries (arch-review C4):
            // browse the address space, run a bounded search, and probe/verify an
            // endpoint. Read-only against the live site server; gated Design to
            // match the connection editor these back.
            or BrowseNodeCommand or SearchAddressSpaceCommand or VerifyEndpointCommand
            or ExportBundleCommand => DesignerOnly,

        // Transport import operations (mirror the Central UI gating:
        // Administrator for inbound bundle handling because they mutate
        // cross-cutting configuration; Export stays Designer because it only
        // reads).
        PreviewBundleCommand or ImportBundleCommand => AdminOnly,

        // Deployer operations
        CreateInstanceCommand or MgmtDeployInstanceCommand or MgmtDeploySiteCommand or MgmtEnableInstanceCommand
            or MgmtDisableInstanceCommand or MgmtDeleteInstanceCommand
            or SetConnectionBindingsCommand or SetInstanceOverridesCommand or SetInstanceAreaCommand
            or SetInstanceAlarmOverrideCommand or DeleteInstanceAlarmOverrideCommand
            or SetInstanceNativeAlarmSourceOverrideCommand or DeleteInstanceNativeAlarmSourceOverrideCommand
            or SetInstanceNativeAlarmSourceOverridesCommand
            or GetDeploymentDiffCommand
            or MgmtDeployArtifactsCommand
            or QueryDeploymentsCommand
            or RetryParkedMessageCommand or DiscardParkedMessageCommand
            or DebugSnapshotCommand => DeployerOnly,

        // Two-person secured write. Submit is an Operator action;
        // approve/reject are Verifier actions. Separation of duties (a write may
        // not be verified by its submitter) is enforced inside the handler — the
        // role gate only ensures the caller holds the right coarse role.
        SubmitSecuredWriteCommand => OperatorOnly,
        RejectSecuredWriteCommand or ApproveSecuredWriteCommand => VerifierOnly,
        // ListSecuredWritesCommand is read-only, but the history table exposes
        // process-sensitive tag values, so it is NOT open to any authenticated
        // user — restricted to the Secured Write roles + Administrator
        // (arch-review UA1).
        ListSecuredWritesCommand => SecuredWriteReaders,

        // Read-only queries -- any authenticated user
        _ => null
    };

    private async Task<object?> DispatchCommand(IServiceProvider sp, object command, AuthenticatedUser user)
    {
        return command switch
        {
            // Templates
            ListTemplatesCommand cmd => await HandleListTemplates(sp, cmd),
            GetTemplateCommand cmd => await HandleGetTemplate(sp, cmd),
            CreateTemplateCommand cmd => await HandleCreateTemplate(sp, cmd, user.Username),
            UpdateTemplateCommand cmd => await HandleUpdateTemplate(sp, cmd, user.Username),
            DeleteTemplateCommand cmd => await HandleDeleteTemplate(sp, cmd, user.Username),
            ValidateTemplateCommand cmd => await HandleValidateTemplate(sp, cmd),
            GetResolvedTemplateMembersCommand cmd => await HandleGetResolvedTemplateMembers(sp, cmd),
            ResyncInheritedMembersCommand cmd => await HandleResyncInheritedMembers(sp, cmd, user.Username),

            // Template members
            AddTemplateAttributeCommand cmd => await HandleAddAttribute(sp, cmd, user.Username),
            UpdateTemplateAttributeCommand cmd => await HandleUpdateAttribute(sp, cmd, user.Username),
            DeleteTemplateAttributeCommand cmd => await HandleDeleteAttribute(sp, cmd, user.Username),
            AddTemplateAlarmCommand cmd => await HandleAddAlarm(sp, cmd, user.Username),
            UpdateTemplateAlarmCommand cmd => await HandleUpdateAlarm(sp, cmd, user.Username),
            DeleteTemplateAlarmCommand cmd => await HandleDeleteAlarm(sp, cmd, user.Username),
            AddTemplateNativeAlarmSourceCommand cmd => await HandleAddNativeAlarmSource(sp, cmd, user.Username),
            UpdateTemplateNativeAlarmSourceCommand cmd => await HandleUpdateNativeAlarmSource(sp, cmd, user.Username),
            DeleteTemplateNativeAlarmSourceCommand cmd => await HandleDeleteNativeAlarmSource(sp, cmd, user.Username),
            ListTemplateNativeAlarmSourcesCommand cmd => await HandleListNativeAlarmSources(sp, cmd),
            AddTemplateScriptCommand cmd => await HandleAddScript(sp, cmd, user.Username),
            UpdateTemplateScriptCommand cmd => await HandleUpdateScript(sp, cmd, user.Username),
            DeleteTemplateScriptCommand cmd => await HandleDeleteScript(sp, cmd, user.Username),
            AddTemplateCompositionCommand cmd => await HandleAddComposition(sp, cmd, user.Username),
            DeleteTemplateCompositionCommand cmd => await HandleDeleteComposition(sp, cmd, user.Username),

            // Template folders
            ListTemplateFoldersCommand => await HandleListTemplateFolders(sp),
            CreateTemplateFolderCommand cmd => await HandleCreateTemplateFolder(sp, cmd, user.Username),
            RenameTemplateFolderCommand cmd => await HandleRenameTemplateFolder(sp, cmd, user.Username),
            MoveTemplateFolderCommand cmd => await HandleMoveTemplateFolder(sp, cmd, user.Username),
            ReorderTemplateFolderCommand cmd => await HandleReorderTemplateFolder(sp, cmd, user.Username),
            DeleteTemplateFolderCommand cmd => await HandleDeleteTemplateFolder(sp, cmd, user.Username),
            MoveTemplateToFolderCommand cmd => await HandleMoveTemplateToFolder(sp, cmd, user.Username),

            // Instances
            ListInstancesCommand cmd => await HandleListInstances(sp, cmd, user),
            GetInstanceCommand cmd => await HandleGetInstance(sp, cmd, user),
            CreateInstanceCommand cmd => await HandleCreateInstance(sp, cmd, user),
            MgmtDeployInstanceCommand cmd => await HandleDeployInstance(sp, cmd, user),
            MgmtDeploySiteCommand cmd => await HandleDeploySite(sp, cmd, user),
            MgmtEnableInstanceCommand cmd => await HandleEnableInstance(sp, cmd, user),
            MgmtDisableInstanceCommand cmd => await HandleDisableInstance(sp, cmd, user),
            MgmtDeleteInstanceCommand cmd => await HandleDeleteInstance(sp, cmd, user),
            SetConnectionBindingsCommand cmd => await HandleSetConnectionBindings(sp, cmd, user),
            SetInstanceOverridesCommand cmd => await HandleSetInstanceOverrides(sp, cmd, user),
            SetInstanceAreaCommand cmd => await HandleSetInstanceArea(sp, cmd, user),
            SetInstanceAlarmOverrideCommand cmd => await HandleSetInstanceAlarmOverride(sp, cmd, user),
            DeleteInstanceAlarmOverrideCommand cmd => await HandleDeleteInstanceAlarmOverride(sp, cmd, user),
            ListInstanceAlarmOverridesCommand cmd => await HandleListInstanceAlarmOverrides(sp, cmd, user),
            SetInstanceNativeAlarmSourceOverrideCommand cmd => await HandleSetInstanceNativeAlarmSourceOverride(sp, cmd, user),
            SetInstanceNativeAlarmSourceOverridesCommand cmd => await HandleSetInstanceNativeAlarmSourceOverrides(sp, cmd, user),
            DeleteInstanceNativeAlarmSourceOverrideCommand cmd => await HandleDeleteInstanceNativeAlarmSourceOverride(sp, cmd, user),
            ListInstanceNativeAlarmSourceOverridesCommand cmd => await HandleListInstanceNativeAlarmSourceOverrides(sp, cmd, user),

            // Sites
            ListSitesCommand => await HandleListSites(sp, user),
            GetSiteCommand cmd => await HandleGetSite(sp, cmd, user),
            CreateSiteCommand cmd => await HandleCreateSite(sp, cmd, user.Username),
            UpdateSiteCommand cmd => await HandleUpdateSite(sp, cmd, user.Username),
            DeleteSiteCommand cmd => await HandleDeleteSite(sp, cmd, user.Username),
            ListAreasCommand cmd => await HandleListAreas(sp, cmd, user),
            CreateAreaCommand cmd => await HandleCreateArea(sp, cmd, user.Username),
            DeleteAreaCommand cmd => await HandleDeleteArea(sp, cmd, user.Username),
            UpdateAreaCommand cmd => await HandleUpdateArea(sp, cmd, user.Username),
            MoveAreaCommand cmd => await HandleMoveArea(sp, cmd, user.Username),

            // Data Connections
            ListDataConnectionsCommand cmd => await HandleListDataConnections(sp, cmd),
            GetDataConnectionCommand cmd => await HandleGetDataConnection(sp, cmd, user),
            CreateDataConnectionCommand cmd => await HandleCreateDataConnection(sp, cmd, user.Username),
            UpdateDataConnectionCommand cmd => await HandleUpdateDataConnection(sp, cmd, user.Username),
            DeleteDataConnectionCommand cmd => await HandleDeleteDataConnection(sp, cmd, user.Username),
            MoveDataConnectionCommand cmd => await HandleMoveDataConnection(sp, cmd, user.Username),

            // External Systems
            ListExternalSystemsCommand => await HandleListExternalSystems(sp),
            GetExternalSystemCommand cmd => await HandleGetExternalSystem(sp, cmd),
            CreateExternalSystemCommand cmd => await HandleCreateExternalSystem(sp, cmd, user.Username),
            UpdateExternalSystemCommand cmd => await HandleUpdateExternalSystem(sp, cmd, user.Username),
            DeleteExternalSystemCommand cmd => await HandleDeleteExternalSystem(sp, cmd, user.Username),
            ListExternalSystemMethodsCommand cmd => await HandleListExternalSystemMethods(sp, cmd),
            GetExternalSystemMethodCommand cmd => await HandleGetExternalSystemMethod(sp, cmd),
            CreateExternalSystemMethodCommand cmd => await HandleCreateExternalSystemMethod(sp, cmd, user.Username),
            UpdateExternalSystemMethodCommand cmd => await HandleUpdateExternalSystemMethod(sp, cmd, user.Username),
            DeleteExternalSystemMethodCommand cmd => await HandleDeleteExternalSystemMethod(sp, cmd, user.Username),

            // Notification Lists
            ListNotificationListsCommand => await HandleListNotificationLists(sp),
            GetNotificationListCommand cmd => await HandleGetNotificationList(sp, cmd),
            CreateNotificationListCommand cmd => await HandleCreateNotificationList(sp, cmd, user.Username),
            UpdateNotificationListCommand cmd => await HandleUpdateNotificationList(sp, cmd, user.Username),
            DeleteNotificationListCommand cmd => await HandleDeleteNotificationList(sp, cmd, user.Username),
            ListSmtpConfigsCommand => await HandleListSmtpConfigs(sp),
            UpdateSmtpConfigCommand cmd => await HandleUpdateSmtpConfig(sp, cmd, user.Username),
            ListSmsConfigsCommand => await HandleListSmsConfigs(sp),
            UpdateSmsConfigCommand cmd => await HandleUpdateSmsConfig(sp, cmd, user.Username),

            // Shared Scripts
            ListSharedScriptsCommand => await HandleListSharedScripts(sp),
            GetSharedScriptCommand cmd => await HandleGetSharedScript(sp, cmd),
            CreateSharedScriptCommand cmd => await HandleCreateSharedScript(sp, cmd, user.Username),
            UpdateSharedScriptCommand cmd => await HandleUpdateSharedScript(sp, cmd, user.Username),
            DeleteSharedScriptCommand cmd => await HandleDeleteSharedScript(sp, cmd, user.Username),

            // Schema Library
            ListSharedSchemasCommand => await HandleListSharedSchemas(sp),
            GetSharedSchemaCommand cmd => await HandleGetSharedSchema(sp, cmd),
            CreateSharedSchemaCommand cmd => await HandleCreateSharedSchema(sp, cmd, user.Username),
            UpdateSharedSchemaCommand cmd => await HandleUpdateSharedSchema(sp, cmd, user.Username),
            DeleteSharedSchemaCommand cmd => await HandleDeleteSharedSchema(sp, cmd, user.Username),

            // Database Connections (External System)
            ListDatabaseConnectionsCommand => await HandleListDatabaseConnections(sp),
            GetDatabaseConnectionCommand cmd => await HandleGetDatabaseConnection(sp, cmd),
            CreateDatabaseConnectionDefCommand cmd => await HandleCreateDatabaseConnection(sp, cmd, user.Username),
            UpdateDatabaseConnectionDefCommand cmd => await HandleUpdateDatabaseConnection(sp, cmd, user.Username),
            DeleteDatabaseConnectionDefCommand cmd => await HandleDeleteDatabaseConnection(sp, cmd, user.Username),

            // Inbound API Methods
            ListApiMethodsCommand => await HandleListApiMethods(sp),
            GetApiMethodCommand cmd => await HandleGetApiMethod(sp, cmd),
            CreateApiMethodCommand cmd => await HandleCreateApiMethod(sp, cmd, user.Username),
            UpdateApiMethodCommand cmd => await HandleUpdateApiMethod(sp, cmd, user.Username),
            DeleteApiMethodCommand cmd => await HandleDeleteApiMethod(sp, cmd, user.Username),

            // Security
            ListRoleMappingsCommand => await HandleListRoleMappings(sp),
            CreateRoleMappingCommand cmd => await HandleCreateRoleMapping(sp, cmd, user.Username),
            UpdateRoleMappingCommand cmd => await HandleUpdateRoleMapping(sp, cmd, user.Username),
            DeleteRoleMappingCommand cmd => await HandleDeleteRoleMapping(sp, cmd, user.Username),
            ListApiKeysCommand => await HandleListApiKeys(sp),
            CreateApiKeyCommand cmd => await HandleCreateApiKey(sp, cmd, user.Username),
            DeleteApiKeyCommand cmd => await HandleDeleteApiKey(sp, cmd, user.Username),
            UpdateApiKeyCommand cmd => await HandleUpdateApiKey(sp, cmd, user.Username),
            SetApiKeyMethodsCommand cmd => await HandleSetApiKeyMethods(sp, cmd, user.Username),
            ListScopeRulesCommand cmd => await HandleListScopeRules(sp, cmd),
            AddScopeRuleCommand cmd => await HandleAddScopeRule(sp, cmd, user.Username),
            DeleteScopeRuleCommand cmd => await HandleDeleteScopeRule(sp, cmd, user.Username),

            // Deployments
            MgmtDeployArtifactsCommand cmd => await HandleDeployArtifacts(sp, cmd, user),
            QueryDeploymentsCommand cmd => await HandleQueryDeployments(sp, cmd, user),
            GetDeploymentDiffCommand cmd => await HandleGetDeploymentDiff(sp, cmd, user),

            // Audit Log
            QueryAuditLogCommand cmd => await HandleQueryAuditLog(sp, cmd),

            // Health
            GetHealthSummaryCommand => HandleGetHealthSummary(sp),
            GetSiteHealthCommand cmd => HandleGetSiteHealth(sp, cmd),

            // Remote Queries
            QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
            BrowseNodeCommand cmd => await HandleBrowseNode(sp, cmd, user),
            SearchAddressSpaceCommand cmd => await HandleSearchAddressSpace(sp, cmd, user),
            VerifyEndpointCommand cmd => await HandleVerifyEndpoint(sp, cmd, user),
            TrustServerCertCommand cmd => await HandleTrustServerCert(sp, cmd, user),
            RemoveServerCertCommand cmd => await HandleRemoveServerCert(sp, cmd, user),
            ListServerCertsCommand cmd => await HandleListServerCerts(sp, cmd, user),
            QueryParkedMessagesCommand cmd => await HandleQueryParkedMessages(sp, cmd, user),
            RetryParkedMessageCommand cmd => await HandleRetryParkedMessage(sp, cmd, user),
            DiscardParkedMessageCommand cmd => await HandleDiscardParkedMessage(sp, cmd, user),
            DebugSnapshotCommand cmd => await HandleDebugSnapshot(sp, cmd, user),

            // Secured writes. Approve executes the write — once a
            // Verifier wins the compare-and-swap the value is relayed to the site
            // MxGateway connection.
            SubmitSecuredWriteCommand cmd => await HandleSubmitSecuredWrite(sp, cmd, user),
            ApproveSecuredWriteCommand cmd => await HandleApproveSecuredWrite(sp, cmd, user),
            RejectSecuredWriteCommand cmd => await HandleRejectSecuredWrite(sp, cmd, user),
            ListSecuredWritesCommand cmd => await HandleListSecuredWrites(sp, cmd, user),

            // Transport bundle operations
            ExportBundleCommand cmd => await HandleExportBundle(sp, cmd, user.Username),
            PreviewBundleCommand cmd => await HandlePreviewBundle(sp, cmd),
            ImportBundleCommand cmd => await HandleImportBundle(sp, cmd, user.Username),

            // NOTE: ResolveRolesCommand is intentionally NOT dispatched. The two-step
            // "ResolveRoles + command" flow is retired — the HTTP endpoint performs LDAP
            // auth and role resolution itself before sending a single envelope. Leaving a
            // handler would expose role-mapping data to any raw in-cluster sender with
            // no role requirement; the command now falls through to the default below.
            _ => throw new NotSupportedException($"Unknown command type: {command.GetType().Name}")
        };
    }

    // ========================================================================
    // Site-scope enforcement
    // ========================================================================

    /// <summary>
    /// Throws SiteScopeViolationException if the user has site-scoped Deployer
    /// and the target site is not in their permitted sites.
    /// Users with the Administrator role, or system-wide Deployer, are not restricted.
    /// </summary>
    private static void EnforceSiteScope(AuthenticatedUser user, int? targetSiteId)
    {
        if (targetSiteId == null) return;
        if (user.PermittedSiteIds.Length == 0) return; // system-wide access
        if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;

        if (!user.PermittedSiteIds.Contains(targetSiteId.Value.ToString()))
        {
            throw new SiteScopeViolationException(
                $"Access denied: your Deployment role is scoped to sites [{string.Join(", ", user.PermittedSiteIds)}] " +
                $"and does not include site {targetSiteId.Value}.");
        }
    }

    /// <summary>
    /// Resolves the site ID for an instance and enforces site-scope.
    /// </summary>
    private static async Task EnforceSiteScopeForInstance(IServiceProvider sp, AuthenticatedUser user, int instanceId)
    {
        if (user.PermittedSiteIds.Length == 0) return;
        if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;

        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var instance = await repo.GetInstanceByIdAsync(instanceId);
        if (instance != null)
            EnforceSiteScope(user, instance.SiteId);
    }

    /// <summary>
    /// Resolves a site by its string identifier and enforces site-scope.
    /// Used by remote-query handlers that key off the site identifier rather than its ID.
    /// </summary>
    private static async Task EnforceSiteScopeForIdentifier(IServiceProvider sp, AuthenticatedUser user, string siteIdentifier)
    {
        if (user.PermittedSiteIds.Length == 0) return;
        if (user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) return;

        var repo = sp.GetRequiredService<ISiteRepository>();
        var site = await repo.GetSiteByIdentifierAsync(siteIdentifier);
        if (site != null)
            EnforceSiteScope(user, site.Id);
    }

    /// <summary>
    /// Logs an audit entry after a successful mutation.
    /// </summary>
    /// <remarks>
    /// Audit-logging contract. Every mutating operation is
    /// audited exactly once, by whichever layer owns the write:
    /// <list type="bullet">
    /// <item>Handlers that mutate a repository directly (site, area, data-connection,
    /// external-system, notification, security, API-key, scope-rule) call this helper
    /// explicitly after the successful change.</item>
    /// <item>Handlers that delegate to a domain service (<c>TemplateService</c>,
    /// <c>SharedScriptService</c>, <c>InstanceService</c>, <c>AreaService</c>,
    /// <c>SiteService</c>, <c>TemplateFolderService</c>, <c>DeploymentService</c>,
    /// <c>ArtifactDeploymentService</c>) do NOT call this helper — those services own their
    /// own <see cref="IAuditService"/> dependency and audit internally. Adding an explicit
    /// <see cref="AuditAsync"/> call in such a handler would double-log.</item>
    /// </list>
    /// </remarks>
    private static async Task AuditAsync(IServiceProvider sp, string user, string action, string entityType, string entityId, string entityName, object? afterState)
    {
        var auditService = sp.GetRequiredService<IAuditService>();
        await auditService.LogAsync(user, action, entityType, entityId, entityName, afterState);
    }

    // ========================================================================
    // Template handlers
    // ========================================================================

    private static async Task<object?> HandleListTemplates(IServiceProvider sp, ListTemplatesCommand cmd)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();

        // Paged and projected in the DATABASE. This used to load every template's
        // full child graph — five Includes under AsSplitQuery, including every
        // script body in the system — and then throw all but one page away in
        // memory, so listing page 1 of 20 cost the same as listing everything.
        // Child collections are reduced to counts, which is what every list
        // surface (CLI table, UI tree) actually renders; a caller that needs a
        // template's members already fetches it with GetTemplate.
        return await repo.GetTemplateSummariesAsync(cmd.Skip, cmd.Take);
    }

    // Additive offset paging (arch-review P2). Take = null (or non-positive) keeps
    // today's unlimited behaviour; Take is clamped to 1..1000; Skip floors at 0.
    // Applied AFTER any in-memory scope filtering by the caller.
    private static List<T> Page<T>(IEnumerable<T> source, int skip, int? take) =>
        source
            .Skip(Math.Max(0, skip))
            .Take(take is > 0 and <= 1000 ? take.Value : int.MaxValue)
            .ToList();

    private static async Task<object?> HandleGetTemplate(IServiceProvider sp, GetTemplateCommand cmd)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        return await repo.GetTemplateWithChildrenAsync(cmd.TemplateId);
    }

    private static async Task<object?> HandleCreateTemplate(IServiceProvider sp, CreateTemplateCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.CreateTemplateAsync(cmd.Name, cmd.Description, cmd.ParentTemplateId, user);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleUpdateTemplate(IServiceProvider sp, UpdateTemplateCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.UpdateTemplateAsync(cmd.TemplateId, cmd.Name, cmd.Description, cmd.ParentTemplateId, user);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteTemplate(IServiceProvider sp, DeleteTemplateCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.DeleteTemplateAsync(cmd.TemplateId, user);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleValidateTemplate(IServiceProvider sp, ValidateTemplateCommand cmd)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();

        // Load the template with all members
        var template = await repo.GetTemplateWithChildrenAsync(cmd.TemplateId)
            ?? throw new ManagementCommandException($"Template with ID {cmd.TemplateId} not found.");

        var attributes = await repo.GetAttributesByTemplateIdAsync(cmd.TemplateId);
        var alarms = await repo.GetAlarmsByTemplateIdAsync(cmd.TemplateId);
        var scripts = await repo.GetScriptsByTemplateIdAsync(cmd.TemplateId);

        // Build a FlattenedConfiguration from the template for the full validation pipeline
        var flatConfig = new Commons.Types.Flattening.FlattenedConfiguration
        {
            InstanceUniqueName = $"validation-{template.Name}",
            TemplateId = template.Id,
            Attributes = attributes.Select(a => new Commons.Types.Flattening.ResolvedAttribute
            {
                CanonicalName = a.Name,
                Value = a.Value,
                DataType = a.DataType.ToString(),
                ElementDataType = a.ElementDataType?.ToString(),
                IsLocked = a.IsLocked,
                DataSourceReference = a.DataSourceReference
            }).ToList(),
            Alarms = alarms.Select(a => new Commons.Types.Flattening.ResolvedAlarm
            {
                CanonicalName = a.Name,
                PriorityLevel = a.PriorityLevel,
                IsLocked = a.IsLocked,
                TriggerType = a.TriggerType.ToString(),
                TriggerConfiguration = a.TriggerConfiguration
            }).ToList(),
            Scripts = scripts.Select(s => new Commons.Types.Flattening.ResolvedScript
            {
                CanonicalName = s.Name,
                Code = s.Code,
                IsLocked = s.IsLocked,
                TriggerType = s.TriggerType,
                TriggerConfiguration = s.TriggerConfiguration,
                ParameterDefinitions = s.ParameterDefinitions,
                ReturnDefinition = s.ReturnDefinition
            }).ToList()
        };

        // Supply the JSON-Schema $ref resolution seam from the shared-schema
        // library so a dangling {"$ref":"lib:Name"} in a template script schema is flagged
        // here (design-time validate) consistently with the deploy path. The library is
        // pre-loaded into a name→JSON map (the seam ValidationService consumes is sync).
        var sharedSchemaRepo = sp.GetRequiredService<ISharedSchemaRepository>();
        var sharedSchemas = await sharedSchemaRepo.ListAsync();
        var schemaLibrary = sharedSchemas.ToDictionary(s => s.Name, s => s.SchemaJson, StringComparer.Ordinal);
        Func<string, string?> resolveSchemaRef = name => schemaLibrary.GetValueOrDefault(name);

        // Also pass shared scripts so a dangling $ref in a SHARED script's schema is
        // caught at design-time (not only on the deploy path). Mirror the deploy-path mapping
        // in FlatteningPipeline: SharedScript entity → ResolvedScript (name + schema fields).
        var sharedScriptEntities = await repo.GetAllSharedScriptsAsync();
        var resolvedSharedScripts = sharedScriptEntities
            .Select(s => new Commons.Types.Flattening.ResolvedScript
            {
                CanonicalName = s.Name,
                Code = s.Code,
                ParameterDefinitions = s.ParameterDefinitions,
                ReturnDefinition = s.ReturnDefinition
            })
            .ToList();

        // Run full validation pipeline (collisions, script compilation, trigger refs, bindings)
        var validationService = new TemplateEngine.Validation.ValidationService();
        var validationResult = validationService.Validate(flatConfig, resolvedSharedScripts, resolveSchemaRef: resolveSchemaRef);

        // Also detect naming collisions across the inheritance/composition graph
        var svc = sp.GetRequiredService<TemplateService>();
        var collisions = await svc.DetectCollisionsAsync(cmd.TemplateId);
        if (collisions.Count > 0)
        {
            var collisionErrors = collisions.Select(c =>
                Commons.Types.Flattening.ValidationEntry.Error(
                    Commons.Types.Flattening.ValidationCategory.NamingCollision, c)).ToArray();
            var collisionResult = new Commons.Types.Flattening.ValidationResult { Errors = collisionErrors };
            validationResult = Commons.Types.Flattening.ValidationResult.Merge(validationResult, collisionResult);
        }

        return validationResult;
    }

    /// <summary>
    /// Read-only authoring resolve: returns the effective inherited
    /// member set for a template — computed fresh from the full inheritance
    /// chain — plus a staleness summary. Loads every template (children
    /// eager-loaded) so the resolver can walk an arbitrary-depth chain; never
    /// mutates stored rows.
    /// </summary>
    private static async Task<object?> HandleGetResolvedTemplateMembers(IServiceProvider sp, GetResolvedTemplateMembersCommand cmd)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var allTemplates = await repo.GetAllTemplatesAsync();
        return TemplateInheritanceResolver.Resolve(cmd.TemplateId, allTemplates);
    }

    /// <summary>
    /// Resync inherited members: reconciles a template's stored
    /// inherited rows (and its derived subtree's) with the resolved effective set —
    /// materializing missing placeholders, re-syncing drifted ones, and removing
    /// orphans — so the editor's editable tabs are complete and the staleness
    /// banner clears. Designer-gated; the service owns the audit row.
    /// </summary>
    private static async Task<object?> HandleResyncInheritedMembers(IServiceProvider sp, ResyncInheritedMembersCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.ResyncInheritedMembersAsync(cmd.TemplateId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    // ========================================================================
    // Template folder handlers
    // ========================================================================

    private static async Task<object?> HandleListTemplateFolders(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        return await repo.GetAllFoldersAsync();
    }

    private static async Task<object?> HandleCreateTemplateFolder(IServiceProvider sp, CreateTemplateFolderCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateFolderService>();
        var result = await svc.CreateFolderAsync(cmd.Name, cmd.ParentFolderId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleRenameTemplateFolder(IServiceProvider sp, RenameTemplateFolderCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateFolderService>();
        var result = await svc.RenameFolderAsync(cmd.FolderId, cmd.NewName, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleMoveTemplateFolder(IServiceProvider sp, MoveTemplateFolderCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateFolderService>();
        var result = await svc.MoveFolderAsync(cmd.FolderId, cmd.NewParentFolderId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleReorderTemplateFolder(IServiceProvider sp, ReorderTemplateFolderCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateFolderService>();
        var result = await svc.ReorderFolderAsync(cmd.FolderId, cmd.Direction, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteTemplateFolder(IServiceProvider sp, DeleteTemplateFolderCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateFolderService>();
        var result = await svc.DeleteFolderAsync(cmd.FolderId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleMoveTemplateToFolder(IServiceProvider sp, MoveTemplateToFolderCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.MoveTemplateAsync(cmd.TemplateId, cmd.NewFolderId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    // ========================================================================
    // Instance handlers
    // ========================================================================

    private static async Task<object?> HandleListInstances(IServiceProvider sp, ListInstancesCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ICentralUiRepository>();
        var instances = await repo.GetInstancesFilteredAsync(cmd.SiteId, cmd.TemplateId, cmd.SearchTerm);
        // Filter by permitted sites for site-scoped users
        if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
        {
            var permittedIds = new HashSet<string>(user.PermittedSiteIds);
            instances = instances.Where(i => permittedIds.Contains(i.SiteId.ToString())).ToList();
        }
        // Paging is applied AFTER the site-scope filter so a scoped user never sees
        // an out-of-scope instance surface into their page window.
        return Page(instances, cmd.Skip, cmd.Take);
    }

    private static async Task<object?> HandleGetInstance(IServiceProvider sp, GetInstanceCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var instance = await repo.GetInstanceByIdAsync(cmd.InstanceId);
        if (instance != null)
            EnforceSiteScope(user, instance.SiteId);
        return instance;
    }

    private static async Task<object?> HandleCreateInstance(IServiceProvider sp, CreateInstanceCommand cmd, AuthenticatedUser user)
    {
        EnforceSiteScope(user, cmd.SiteId);
        var svc = sp.GetRequiredService<InstanceService>();
        var result = await svc.CreateInstanceAsync(cmd.UniqueName, cmd.TemplateId, cmd.SiteId, cmd.AreaId, user.Username);
        if (!result.IsSuccess) throw new ManagementCommandException(result.Error);
        await AuditAsync(sp, user.Username, "Create", "Instance", result.Value.Id.ToString(), result.Value.UniqueName, result.Value);
        return result.Value;
    }

    private static async Task<object?> HandleDeployInstance(IServiceProvider sp, MgmtDeployInstanceCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<DeploymentService>();
        var result = await svc.DeployInstanceAsync(cmd.InstanceId, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    /// <summary>
    /// Bulk-deploys every deployable instance at a site.
    ///
    /// <para>
    /// Site scope is enforced on the SITE (not per instance) because the site is
    /// the command's target: a site-scoped Deployer may bulk-deploy only sites in
    /// their scope, and every instance the batch touches belongs to that site by
    /// construction — so the per-instance check would be redundant.
    /// </para>
    /// </summary>
    private static async Task<object?> HandleDeploySite(IServiceProvider sp, MgmtDeploySiteCommand cmd, AuthenticatedUser user)
    {
        EnforceSiteScope(user, cmd.SiteId);
        var svc = sp.GetRequiredService<DeploymentService>();
        var result = await svc.DeploySiteAsync(cmd.SiteId, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleEnableInstance(IServiceProvider sp, MgmtEnableInstanceCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<DeploymentService>();
        var result = await svc.EnableInstanceAsync(cmd.InstanceId, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDisableInstance(IServiceProvider sp, MgmtDisableInstanceCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<DeploymentService>();
        var result = await svc.DisableInstanceAsync(cmd.InstanceId, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteInstance(IServiceProvider sp, MgmtDeleteInstanceCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<DeploymentService>();
        var result = await svc.DeleteInstanceAsync(cmd.InstanceId, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleSetConnectionBindings(IServiceProvider sp, SetConnectionBindingsCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<InstanceService>();
        var result = await svc.SetConnectionBindingsAsync(cmd.InstanceId, cmd.Bindings, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleSetInstanceOverrides(IServiceProvider sp, SetInstanceOverridesCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);

        // Multi-override apply is all-or-nothing.
        // InstanceService.SetAttributeOverrideAsync commits each override
        // independently, so a mid-batch failure on an invalid attribute would
        // otherwise leave the instance partially mutated. Validate every
        // requested attribute up front against the instance's template; only
        // apply once the whole batch is known to be valid. (A genuine database
        // fault mid-apply remains theoretically possible without a shared
        // transaction, but the realistic failure modes — unknown or locked
        // attribute — are now rejected before any write.)
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var instance = await repo.GetInstanceByIdAsync(cmd.InstanceId)
            ?? throw new ManagementCommandException($"Instance with ID {cmd.InstanceId} not found.");

        var templateAttrs = await repo.GetAttributesByTemplateIdAsync(instance.TemplateId);
        var attrsByName = templateAttrs.ToDictionary(a => a.Name);
        foreach (var attrName in cmd.Overrides.Keys)
        {
            if (!attrsByName.TryGetValue(attrName, out var templateAttr))
                throw new ManagementCommandException(
                    $"Attribute '{attrName}' does not exist in template {instance.TemplateId}. " +
                    "No overrides were applied.");
            if (templateAttr.IsLocked)
                throw new ManagementCommandException(
                    $"Attribute '{attrName}' is locked and cannot be overridden. No overrides were applied.");
        }

        // Apply as ONE read + ONE commit. The previous loop called
        // InstanceService.SetAttributeOverrideAsync per entry, and each of those
        // re-read the instance, re-read the template's attributes, re-read the
        // instance's existing overrides, then committed and wrote an audit row —
        // K read-modify-commit cycles for what is logically one batch. That is
        // both K× the round trips and K× the windows in which a mid-batch fault
        // can leave the instance partially mutated, which is exactly what the
        // pre-validation above was bolted on to mitigate.
        //
        // Now: the existing override rows are read once in bulk, every entry is
        // added or updated against that snapshot, and a single SaveChangesAsync
        // commits the whole batch — making the all-or-nothing promise real rather
        // than best-effort. One audit row summarises the batch.
        var existingOverrides = await repo.GetOverridesByInstanceIdAsync(cmd.InstanceId);
        var existingByName = existingOverrides
            .GroupBy(o => o.AttributeName, StringComparer.Ordinal)
            .ToDictionary(g => g.Key, g => g.First(), StringComparer.Ordinal);

        var results = new List<InstanceAttributeOverride>(cmd.Overrides.Count);
        foreach (var (attrName, overrideValue) in cmd.Overrides)
        {
            // attrsByName lookup is guaranteed to hit — the pre-validation loop
            // above rejects the whole batch on an unknown or locked attribute.
            var elementDataType = attrsByName[attrName].ElementDataType;

            if (existingByName.TryGetValue(attrName, out var existing))
            {
                existing.OverrideValue = overrideValue;
                existing.ElementDataType = elementDataType;
                await repo.UpdateInstanceAttributeOverrideAsync(existing);
                results.Add(existing);
            }
            else
            {
                var created = new InstanceAttributeOverride(attrName)
                {
                    InstanceId = cmd.InstanceId,
                    OverrideValue = overrideValue,
                    ElementDataType = elementDataType
                };
                await repo.AddInstanceAttributeOverrideAsync(created);
                results.Add(created);
            }
        }

        await repo.SaveChangesAsync();

        var auditService = sp.GetRequiredService<IAuditService>();
        await auditService.LogAsync(
            user.Username, "SetOverrides", "Instance", cmd.InstanceId.ToString(), instance.UniqueName,
            new { Count = results.Count, Attributes = cmd.Overrides.Keys.ToArray() });

        return results;
    }

    private static async Task<object?> HandleSetInstanceArea(IServiceProvider sp, SetInstanceAreaCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<InstanceService>();
        var result = await svc.AssignToAreaAsync(cmd.InstanceId, cmd.AreaId, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleSetInstanceAlarmOverride(IServiceProvider sp, SetInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<InstanceService>();
        var result = await svc.SetAlarmOverrideAsync(
            cmd.InstanceId, cmd.AlarmCanonicalName,
            cmd.TriggerConfigurationOverride, cmd.PriorityLevelOverride,
            user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteInstanceAlarmOverride(IServiceProvider sp, DeleteInstanceAlarmOverrideCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<InstanceService>();
        var result = await svc.DeleteAlarmOverrideAsync(
            cmd.InstanceId, cmd.AlarmCanonicalName, user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleListInstanceAlarmOverrides(IServiceProvider sp, ListInstanceAlarmOverridesCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        return await repo.GetAlarmOverridesByInstanceIdAsync(cmd.InstanceId);
    }

    private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverride(
        IServiceProvider sp, SetInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();

        // Enforce template-level locks (and reject dangling overrides) before persisting.
        // Flatten the instance (script compilation skipped — a lock check does not need it)
        // and look up the resolved source by canonical name: a locked source cannot be
        // overridden, and an unresolved name would create a dead override the flattener
        // silently drops.
        var pipeline = sp.GetRequiredService<IFlatteningPipeline>();
        var flattenResult = await pipeline.FlattenAndValidateAsync(
            cmd.InstanceId, default, validateScripts: false);
        if (flattenResult.IsFailure)
            throw new ManagementCommandException(
                $"Cannot set native alarm source override: the instance could not be flattened ({flattenResult.Error}).");

        var resolvedSource = flattenResult.Value.Configuration.NativeAlarmSources
            .FirstOrDefault(s => string.Equals(s.CanonicalName, cmd.SourceCanonicalName, StringComparison.Ordinal));
        if (resolvedSource == null)
            throw new ManagementCommandException(
                $"Native alarm source '{cmd.SourceCanonicalName}' does not resolve for this instance and cannot be overridden.");
        if (resolvedSource.IsLocked)
            throw new ManagementCommandException(
                $"Native alarm source '{cmd.SourceCanonicalName}' is locked at the template level and cannot be overridden.");

        var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
        if (existing == null)
        {
            var ovr = new InstanceNativeAlarmSourceOverride(cmd.SourceCanonicalName)
            {
                InstanceId = cmd.InstanceId,
                ConnectionNameOverride = cmd.ConnectionNameOverride,
                SourceReferenceOverride = cmd.SourceReferenceOverride,
                ConditionFilterOverride = cmd.ConditionFilterOverride
            };
            await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
            await repo.SaveChangesAsync();
            return ovr;
        }

        existing.ConnectionNameOverride = cmd.ConnectionNameOverride;
        existing.SourceReferenceOverride = cmd.SourceReferenceOverride;
        existing.ConditionFilterOverride = cmd.ConditionFilterOverride;
        await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
        await repo.SaveChangesAsync();
        return existing;
    }

    private static async Task<object?> HandleSetInstanceNativeAlarmSourceOverrides(
        IServiceProvider sp, SetInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();

        // All-or-nothing (parity with HandleSetInstanceOverrides). Flatten the
        // instance ONCE (script compilation skipped — a lock/resolve check does not
        // need it) and validate EVERY requested source up front: a source that does
        // not resolve would create a dead override the flattener silently drops, and a
        // template-locked source cannot be overridden. Only once the whole batch is
        // known valid do we upsert — a mid-batch reject never leaves partial state.
        var pipeline = sp.GetRequiredService<IFlatteningPipeline>();
        var flattenResult = await pipeline.FlattenAndValidateAsync(
            cmd.InstanceId, default, validateScripts: false);
        if (flattenResult.IsFailure)
            throw new ManagementCommandException(
                $"Cannot set native alarm source overrides: the instance could not be flattened ({flattenResult.Error}).");

        var resolvedByName = flattenResult.Value.Configuration.NativeAlarmSources
            .ToDictionary(s => s.CanonicalName, StringComparer.Ordinal);

        // Reject a batch that names the same source twice — the intended override
        // would be ambiguous, and the last-writer-wins upsert would silently discard
        // the earlier row.
        var seen = new HashSet<string>(StringComparer.Ordinal);
        foreach (var entry in cmd.Overrides)
        {
            if (!resolvedByName.TryGetValue(entry.SourceCanonicalName, out var resolvedSource))
                throw new ManagementCommandException(
                    $"Native alarm source '{entry.SourceCanonicalName}' does not resolve for this instance " +
                    "and cannot be overridden. No overrides were applied.");
            if (resolvedSource.IsLocked)
                throw new ManagementCommandException(
                    $"Native alarm source '{entry.SourceCanonicalName}' is locked at the template level and " +
                    "cannot be overridden. No overrides were applied.");
            if (!seen.Add(entry.SourceCanonicalName))
                throw new ManagementCommandException(
                    $"Native alarm source '{entry.SourceCanonicalName}' appears more than once in the batch. " +
                    "No overrides were applied.");
        }

        var results = new List<InstanceNativeAlarmSourceOverride>();
        foreach (var entry in cmd.Overrides)
        {
            var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, entry.SourceCanonicalName);
            if (existing == null)
            {
                var ovr = new InstanceNativeAlarmSourceOverride(entry.SourceCanonicalName)
                {
                    InstanceId = cmd.InstanceId,
                    ConnectionNameOverride = entry.ConnectionNameOverride,
                    SourceReferenceOverride = entry.SourceReferenceOverride,
                    ConditionFilterOverride = entry.ConditionFilterOverride
                };
                await repo.AddInstanceNativeAlarmSourceOverrideAsync(ovr);
                results.Add(ovr);
            }
            else
            {
                existing.ConnectionNameOverride = entry.ConnectionNameOverride;
                existing.SourceReferenceOverride = entry.SourceReferenceOverride;
                existing.ConditionFilterOverride = entry.ConditionFilterOverride;
                await repo.UpdateInstanceNativeAlarmSourceOverrideAsync(existing);
                results.Add(existing);
            }
        }

        await repo.SaveChangesAsync();
        return results;
    }

    private static async Task<object?> HandleDeleteInstanceNativeAlarmSourceOverride(
        IServiceProvider sp, DeleteInstanceNativeAlarmSourceOverrideCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();

        var existing = await repo.GetNativeAlarmSourceOverrideAsync(cmd.InstanceId, cmd.SourceCanonicalName);
        if (existing != null)
        {
            await repo.DeleteInstanceNativeAlarmSourceOverrideAsync(existing.Id);
            await repo.SaveChangesAsync();
        }
        return cmd.SourceCanonicalName;
    }

    private static async Task<object?> HandleListInstanceNativeAlarmSourceOverrides(
        IServiceProvider sp, ListInstanceNativeAlarmSourceOverridesCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        return await repo.GetNativeAlarmSourceOverridesByInstanceIdAsync(cmd.InstanceId);
    }

    private static async Task<object?> HandleGetDeploymentDiff(IServiceProvider sp, GetDeploymentDiffCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId);
        var svc = sp.GetRequiredService<DeploymentService>();
        var result = await svc.GetDeploymentComparisonAsync(cmd.InstanceId);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleRetryParkedMessage(IServiceProvider sp, RetryParkedMessageCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
        var commService = sp.GetRequiredService<CommunicationService>();
        var request = new Commons.Messages.RemoteQuery.ParkedMessageRetryRequest(
            Guid.NewGuid().ToString("N"), cmd.SiteIdentifier, cmd.MessageId, DateTimeOffset.UtcNow);
        return await commService.RetryParkedMessageAsync(cmd.SiteIdentifier, request);
    }

    private static async Task<object?> HandleDiscardParkedMessage(IServiceProvider sp, DiscardParkedMessageCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
        var commService = sp.GetRequiredService<CommunicationService>();
        var request = new Commons.Messages.RemoteQuery.ParkedMessageDiscardRequest(
            Guid.NewGuid().ToString("N"), cmd.SiteIdentifier, cmd.MessageId, DateTimeOffset.UtcNow);
        return await commService.DiscardParkedMessageAsync(cmd.SiteIdentifier, request);
    }

    // ========================================================================
    // Secured-write handlers
    //
    // Two-person workflow: an Operator submits a pending write against an
    // MxGateway data connection; a distinct Verifier approves or
    // rejects it. The role gate (GetRequiredRole) only verifies the coarse role;
    // the separation-of-duties rule (a write may not be verified by its
    // submitter) is enforced here.
    // ========================================================================

    private static SecuredWriteDto ToSecuredWriteDto(PendingSecuredWrite e) => new(
        e.Id, e.SiteId, e.ConnectionName, e.TagPath, e.ValueJson, e.ValueType,
        e.Status, e.OperatorUser, e.OperatorComment, e.SubmittedAtUtc,
        e.VerifierUser, e.VerifierComment, e.DecidedAtUtc, e.ExecutedAtUtc, e.ExecutionError);

    /// <summary>
    /// Deterministic, reversible map from a <see cref="PendingSecuredWrite.Id"/> (a
    /// store-assigned <see cref="long"/>) to the canonical AuditLog
    /// <c>CorrelationId</c> (a <see cref="Guid"/>): the 8-byte big-endian id occupies
    /// the final 8 bytes of an otherwise-zero Guid. Every row across one secured-write
    /// lifecycle (submit → approve → execute, or submit → reject) shares this value so
    /// they join into one operation; the encoding is stable (same id ⇒ same Guid).
    /// </summary>
    private static Guid SecuredWriteCorrelation(long id)
    {
        Span<byte> bytes = stackalloc byte[16];
        BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
        return new Guid(bytes);
    }

    /// <summary>
    /// Best-effort emission of ONE secured-write AuditLog row via the central direct-write
    /// path (<see cref="ICentralAuditWriter.WriteAsync"/>) — mirrors the Notification
    /// Outbox dispatch / Inbound API central-origin pattern. The row is built through the
    /// canonical <see cref="ScadaBridgeAuditEventFactory"/> so Action/Category/Outcome map
    /// identically to every other emit site.
    /// </summary>
    /// <remarks>
    /// Routes through <see cref="ICentralAuditWriter"/> (rather than calling
    /// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> directly) so the writing
    /// central node's <c>SourceNode</c> (<c>central-a</c>/<c>central-b</c>) is stamped via
    /// the writer's <see cref="INodeIdentityProvider"/> — satisfying the design's
    /// node-of-origin invariant. The rows are otherwise unchanged (same channel/kind,
    /// same <c>CorrelationId</c> = the row id, same payload).
    /// <para>
    /// Standing audit invariant: an audit-write failure NEVER aborts the secured-write
    /// action. <see cref="ICentralAuditWriter"/> is best-effort and swallows its own
    /// internal failures; the surrounding try/catch is defensive belt-and-braces (it also
    /// guards writer resolution) — either way the caller's own success/failure path is
    /// authoritative.
    /// </para>
    /// </remarks>
    private static async Task EmitSecuredWriteAuditAsync(
        IServiceProvider sp,
        AuditKind kind,
        AuditStatus status,
        PendingSecuredWrite row,
        string actor,
        string? errorMessage = null)
    {
        try
        {
            var evt = ScadaBridgeAuditEventFactory.Create(
                channel: AuditChannel.SecuredWrite,
                kind: kind,
                status: status,
                actor: actor,
                target: $"{row.SiteId}/{row.ConnectionName}/{row.TagPath}",
                correlationId: SecuredWriteCorrelation(row.Id),
                sourceSiteId: row.SiteId,
                errorMessage: errorMessage,
                // Carry the counterparty (operator on a verifier-actioned row, and
                // vice-versa) so a single row names both parties to the two-person write.
                extra: JsonSerializer.Serialize(new
                {
                    operatorUser = row.OperatorUser,
                    verifierUser = row.VerifierUser
                }));

            // The central direct-write writer is a singleton that opens its own
            // per-call scope for the (scoped) IAuditLogRepository and stamps SourceNode
            // from the local INodeIdentityProvider — no scope needed here.
            var auditWriter = sp.GetRequiredService<ICentralAuditWriter>();
            await auditWriter.WriteAsync(evt);
        }
        catch (Exception ex)
        {
            // Audit is best-effort — swallow + log; never abort the secured-write action.
            sp.GetService<ILogger<ManagementActor>>()?.LogWarning(
                ex,
                "Best-effort secured-write audit emission failed (kind={Kind}, id={Id}); the write itself is unaffected.",
                kind, row.Id);
        }
    }

    /// <summary>If the Pending row is older than the configured TTL, CAS it to Expired
    /// (multi-node idempotent), audit best-effort, and return true. A stale setpoint
    /// must never be approvable days later (arch-review S2).</summary>
    private static async Task<bool> TryExpireIfStaleAsync(
        IServiceProvider sp, ISecuredWriteRepository repo, PendingSecuredWrite row)
    {
        var ttl = sp.GetService<IOptions<ManagementServiceOptions>>()?.Value.SecuredWritePendingTtl
                  ?? TimeSpan.FromHours(24);
        if (ttl <= TimeSpan.Zero || DateTime.UtcNow - row.SubmittedAtUtc <= ttl) return false;
        var expiredAt = DateTime.UtcNow;
        if (await repo.TryMarkExpiredAsync(row.Id, expiredAt))
        {
            row.Status = "Expired";
            row.DecidedAtUtc = expiredAt;
            // No Discarded-equivalent below Expired in the AuditStatus enum; Discarded is
            // the closest terminal status (the write was abandoned, never executed).
            await EmitSecuredWriteAuditAsync(
                sp, AuditKind.SecuredWriteExpire, AuditStatus.Discarded, row, actor: "system");
        }
        return true;
    }

    private static async Task<object?> HandleSubmitSecuredWrite(
        IServiceProvider sp, SubmitSecuredWriteCommand cmd, AuthenticatedUser user)
    {
        var siteRepo = sp.GetRequiredService<ISiteRepository>();
        var site = await siteRepo.GetSiteByIdentifierAsync(cmd.SiteId)
            ?? throw new ManagementCommandException($"Site '{cmd.SiteId}' not found.");

        // Site scope (arch-review R2 N3): an LDAP-mapping-scoped Operator may only
        // submit device writes to their permitted sites — same rule C2 applies to
        // DeployArtifacts, on a higher-consequence path. Checked before the row exists.
        EnforceSiteScope(user, site.Id);

        var connections = await siteRepo.GetDataConnectionsBySiteIdAsync(site.Id);
        var conn = connections.FirstOrDefault(c =>
            string.Equals(c.Name, cmd.ConnectionName, StringComparison.Ordinal))
            ?? throw new ManagementCommandException(
                $"Data connection '{cmd.ConnectionName}' not found on site '{cmd.SiteId}'.");

        // Secured writes only apply to MxGateway connections.
        if (!string.Equals(conn.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
            throw new ManagementCommandException(
                $"Secured writes require an MxGateway connection; '{cmd.ConnectionName}' uses protocol '{conn.Protocol}'.");

        var entity = new PendingSecuredWrite
        {
            SiteId = cmd.SiteId,
            ConnectionName = cmd.ConnectionName,
            TagPath = cmd.TagPath,
            ValueJson = cmd.ValueJson,
            ValueType = cmd.ValueType,
            Status = "Pending",
            OperatorUser = user.Username,
            OperatorComment = cmd.Comment,
            SubmittedAtUtc = DateTime.UtcNow
        };

        var repo = sp.GetRequiredService<ISecuredWriteRepository>();
        entity.Id = await repo.AddAsync(entity);

        // One append-only audit row per lifecycle event. Emitted AFTER the row is
        // persisted (so it carries the store-assigned id); best-effort — see helper.
        await EmitSecuredWriteAuditAsync(
            sp, AuditKind.SecuredWriteSubmit, AuditStatus.Submitted, entity, actor: entity.OperatorUser);

        return ToSecuredWriteDto(entity);
    }

    private static async Task<object?> HandleApproveSecuredWrite(
        IServiceProvider sp, ApproveSecuredWriteCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ISecuredWriteRepository>();
        var row = await repo.GetAsync(cmd.Id)
            ?? throw new ManagementCommandException($"Secured write {cmd.Id} not found.");

        if (!string.Equals(row.Status, "Pending", StringComparison.Ordinal))
            throw new ManagementCommandException(
                $"Secured write {cmd.Id} is '{row.Status}', not Pending; it cannot be approved.");

        // Site scope (arch-review R2 N3): the approving Verifier must be permitted on
        // the row's target site — checked BEFORE the TTL/self-approval/CAS chain so an
        // out-of-scope verifier can neither consume the Pending transition nor relay.
        await EnforceSiteScopeForIdentifier(sp, user, row.SiteId);

        // Server-side TTL: a Pending write older than the configured age is transitioned
        // to Expired and can never be approved — the stale setpoint is never relayed
        // (arch-review S2). Checked BEFORE the self-approval/CAS so an overdue row never
        // executes, regardless of who touches it.
        if (await TryExpireIfStaleAsync(sp, repo, row))
            throw new ManagementCommandException(
                $"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");

        // Separation of duties: a write may not be verified by its submitter. Checked
        // BEFORE the CAS so a self-approval never consumes the Pending->Approved
        // transition.
        if (string.Equals(row.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
            throw new ManagementCommandException(
                "A secured write cannot be verified by its submitter.");

        // Compare-and-swap: guards the two-verifier race. Exactly one concurrent
        // approver flips Pending->Approved; the loser observes false here and must
        // not relay the write.
        var decidedAtUtc = DateTime.UtcNow;
        if (!await repo.TryMarkApprovedAsync(cmd.Id, user.Username, cmd.Comment, decidedAtUtc))
            throw new ManagementCommandException(
                $"Secured write {cmd.Id} is no longer pending — already decided.");

        // We won the race. Stamp the verifier decision locally so the entity we
        // persist below carries the same values the CAS committed.
        row.Status = "Approved";
        row.VerifierUser = user.Username;
        row.VerifierComment = cmd.Comment;
        row.DecidedAtUtc = decidedAtUtc;

        // The approval decision is itself an audited lifecycle event (the
        // verifier won the CAS). Emitted with the in-flight Submitted status; the
        // Execute row below records the terminal write outcome.
        await EmitSecuredWriteAuditAsync(
            sp, AuditKind.SecuredWriteApprove, AuditStatus.Submitted, row, actor: user.Username);

        // TOCTOU guard: re-assert the MxGateway protocol AT EXECUTE, not only at
        // submit. The connection named on the row may have been reconfigured/recreated as a
        // non-MxGateway (e.g. OPC UA) connection between submit and approval; relaying then
        // would execute the secured write against a non-MxGateway adapter, violating the
        // feature's core safety invariant. Re-load the connection with the same lookup
        // submit uses (resolve site -> its data connections -> match by name) and fail the
        // row deterministically (mirrors the decode-error containment below) if it is
        // missing or no longer MxGateway — WITHOUT relaying.
        var siteRepo = sp.GetRequiredService<ISiteRepository>();
        var execSite = await siteRepo.GetSiteByIdentifierAsync(row.SiteId);
        var execConn = execSite is null
            ? null
            : (await siteRepo.GetDataConnectionsBySiteIdAsync(execSite.Id))
                .FirstOrDefault(c => string.Equals(c.Name, row.ConnectionName, StringComparison.Ordinal));
        if (execConn is null ||
            !string.Equals(execConn.Protocol, "MxGateway", StringComparison.OrdinalIgnoreCase))
        {
            row.Status = "Failed";
            row.ExecutedAtUtc = DateTime.UtcNow;
            row.ExecutionError = execConn is null
                ? $"connection '{row.ConnectionName}' not found"
                : $"connection '{row.ConnectionName}' is no longer an MxGateway connection";
            await repo.UpdateAsync(row);
            await EmitSecuredWriteAuditAsync(
                sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
                actor: user.Username, errorMessage: row.ExecutionError);
            return ToSecuredWriteDto(row);
        }

        // Validate the value type BEFORE attempting the relay. An unknown type can
        // never be decoded/written, so fail the row deterministically rather than
        // leaving it stuck Approved. (Addresses the reviewer's deferred
        // ValueType-validation note.)
        if (!Enum.TryParse<Commons.Types.Enums.DataType>(row.ValueType, ignoreCase: true, out var dataType))
        {
            row.Status = "Failed";
            row.ExecutedAtUtc = DateTime.UtcNow;
            row.ExecutionError = "unknown value type";
            await repo.UpdateAsync(row);
            await EmitSecuredWriteAuditAsync(
                sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
                actor: user.Username, errorMessage: row.ExecutionError);
            return ToSecuredWriteDto(row);
        }

        // Robustness fix: Decode is UNGUARDED without a try/catch — a List-typed
        // value carrying corrupt JSON throws out of the handler and leaves the row
        // stuck Approved. Contain it: fail the row deterministically with the decode
        // error, audit the failure, and return WITHOUT relaying (nothing to write).
        object? value;
        try
        {
            value = Commons.Types.AttributeValueCodec.Decode(row.ValueJson, dataType, elementType: null);
        }
        catch (Exception ex)
        {
            row.Status = "Failed";
            row.ExecutedAtUtc = DateTime.UtcNow;
            row.ExecutionError = $"value decode error: {ex.Message}";
            await repo.UpdateAsync(row);
            await EmitSecuredWriteAuditAsync(
                sp, AuditKind.SecuredWriteExecute, AuditStatus.Failed, row,
                actor: user.Username, errorMessage: row.ExecutionError);
            return ToSecuredWriteDto(row);
        }

        // Relay the write to the site MxGateway connection. A transport exception is
        // contained so the row is never left stuck Approved.
        var commService = sp.GetRequiredService<CommunicationService>();
        bool success;
        string? error;
        try
        {
            var resp = await commService.WriteTagAsync(
                row.SiteId,
                new Commons.Messages.DataConnection.WriteTagRequest(
                    CorrelationId: Guid.NewGuid().ToString("N"),
                    ConnectionName: row.ConnectionName,
                    TagPath: row.TagPath,
                    Value: value,
                    Timestamp: DateTimeOffset.UtcNow));
            success = resp.Success;
            error = resp.Success ? null : resp.ErrorMessage;
        }
        catch (Exception ex)
        {
            success = false;
            error = ex.Message;
        }

        row.Status = success ? "Executed" : "Failed";
        row.ExecutedAtUtc = DateTime.UtcNow;
        row.ExecutionError = error;

        // UpdateAsync overwrites all columns -> pass the fully-populated entity.
        await repo.UpdateAsync(row);

        // Terminal execute outcome: Delivered (relay succeeded) maps to canonical
        // Success, Failed maps to canonical Failure (the error rides in the row detail).
        await EmitSecuredWriteAuditAsync(
            sp,
            AuditKind.SecuredWriteExecute,
            success ? AuditStatus.Delivered : AuditStatus.Failed,
            row,
            actor: user.Username,
            errorMessage: error);

        return ToSecuredWriteDto(row);
    }

    private static async Task<object?> HandleRejectSecuredWrite(
        IServiceProvider sp, RejectSecuredWriteCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ISecuredWriteRepository>();
        var entity = await repo.GetAsync(cmd.Id)
            ?? throw new ManagementCommandException($"Secured write {cmd.Id} not found.");

        if (!string.Equals(entity.Status, "Pending", StringComparison.Ordinal))
            throw new ManagementCommandException(
                $"Secured write {cmd.Id} is '{entity.Status}', not Pending; it cannot be rejected.");

        // Site scope (arch-review R2 N3): the rejecting Verifier must be permitted on
        // the row's target site — checked BEFORE the TTL/self-approval chain.
        await EnforceSiteScopeForIdentifier(sp, user, entity.SiteId);

        // Server-side TTL: an overdue Pending write expires here too, so a reject on a
        // stale row deterministically reports the expiry rather than recording a
        // human decision (arch-review S2).
        if (await TryExpireIfStaleAsync(sp, repo, entity))
            throw new ManagementCommandException(
                $"Secured write {cmd.Id} expired (pending longer than the configured TTL) and can no longer be approved or rejected.");

        // Separation of duties: the verifier must differ from the submitter.
        if (string.Equals(entity.OperatorUser, user.Username, StringComparison.OrdinalIgnoreCase))
            throw new ManagementCommandException(
                "A secured write cannot be verified by its submitter.");

        entity.Status = "Rejected";
        entity.VerifierUser = user.Username;
        entity.VerifierComment = cmd.Comment;
        entity.DecidedAtUtc = DateTime.UtcNow;

        // UpdateAsync overwrites all columns -> pass the fully-populated entity.
        await repo.UpdateAsync(entity);

        // Reject is a terminal lifecycle event (canonical Discarded outcome).
        // Actor = the verifier; the operator is carried in the row's extra detail.
        await EmitSecuredWriteAuditAsync(
            sp, AuditKind.SecuredWriteReject, AuditStatus.Discarded, entity, actor: user.Username);

        return ToSecuredWriteDto(entity);
    }

    private static async Task<object?> HandleListSecuredWrites(
        IServiceProvider sp, ListSecuredWritesCommand cmd, AuthenticatedUser user)
    {
        // Site scoping (arch-review R2 N3): an explicit SiteId filter is scope-checked
        // like every other identifier-keyed command; an UNFILTERED list from a
        // site-scoped non-admin caller is constrained to their permitted sites at the
        // query level (rows AND totalCount both scoped — see ISecuredWriteRepository).
        IReadOnlyCollection<string>? permittedIdentifiers = null;
        if (cmd.SiteId is not null)
        {
            await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteId);
        }
        else if (user.PermittedSiteIds.Length > 0
            && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
        {
            var siteRepo = sp.GetRequiredService<ISiteRepository>();
            var permitted = new HashSet<string>(user.PermittedSiteIds);
            permittedIdentifiers = (await siteRepo.GetAllSitesAsync())
                .Where(s => permitted.Contains(s.Id.ToString()))
                .Select(s => s.SiteIdentifier)
                .ToList();
        }

        var repo = sp.GetRequiredService<ISecuredWriteRepository>();

        // Offset paging (arch-review P3): clamp untrusted paging inputs so a crafted
        // command can neither request a negative offset nor an unbounded page.
        var take = Math.Clamp(cmd.Take, 1, 500);
        var skip = Math.Max(0, cmd.Skip);
        var rows = await repo.QueryAsync(cmd.Status, cmd.SiteId, skip, take, permittedIdentifiers);
        var totalCount = await repo.CountAsync(cmd.Status, cmd.SiteId, permittedIdentifiers);

        // Opportunistic expiry sweep (arch-review S2): every time the list is read, walk
        // the page and expire any overdue Pending row. TryExpireIfStaleAsync self-filters
        // by age (fresh rows are no-ops) and CAS-guards the transition, so this is
        // deliberately lazy/opportunistic — the ManagementActor already runs on every
        // central node, so a dedicated expiry-timer singleton would be redundant, and the
        // compare-and-swap makes two nodes sweeping the same overdue row concurrently
        // harmless (exactly one wins; the loser observes false and re-projects the row's
        // freshly-Expired state). The mutation lands on the in-memory row before projection,
        // so the caller sees Expired in the same response.
        foreach (var row in rows)
        {
            if (string.Equals(row.Status, "Pending", StringComparison.Ordinal))
                await TryExpireIfStaleAsync(sp, repo, row);
        }

        return new SecuredWriteListResult(rows.Select(ToSecuredWriteDto).ToList(), totalCount);
    }

    // ========================================================================
    // Site handlers
    // ========================================================================

    private static async Task<object?> HandleListSites(IServiceProvider sp, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var sites = await repo.GetAllSitesAsync();
        if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
        {
            var permittedIds = new HashSet<string>(user.PermittedSiteIds);
            sites = sites.Where(s => permittedIds.Contains(s.Id.ToString())).ToList();
        }
        return sites;
    }

    private static async Task<object?> HandleGetSite(IServiceProvider sp, GetSiteCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var site = await repo.GetSiteByIdAsync(cmd.SiteId);
        if (site != null)
            EnforceSiteScope(user, site.Id);
        return site;
    }

    private static async Task<object?> HandleCreateSite(IServiceProvider sp, CreateSiteCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var site = new Site(cmd.Name, cmd.SiteIdentifier)
        {
            Description = cmd.Description,
            NodeAAddress = cmd.NodeAAddress,
            NodeBAddress = cmd.NodeBAddress,
            GrpcNodeAAddress = cmd.GrpcNodeAAddress,
            GrpcNodeBAddress = cmd.GrpcNodeBAddress
        };
        await repo.AddSiteAsync(site);
        await repo.SaveChangesAsync();
        var commService = sp.GetService<CommunicationService>();
        commService?.RefreshSiteAddresses();
        await AuditAsync(sp, user, "Create", "Site", site.Id.ToString(), site.Name, site);
        return site;
    }

    private static async Task<object?> HandleUpdateSite(IServiceProvider sp, UpdateSiteCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var site = await repo.GetSiteByIdAsync(cmd.SiteId)
            ?? throw new ManagementCommandException($"Site with ID {cmd.SiteId} not found.");
        site.Name = cmd.Name;
        site.Description = cmd.Description;
        site.NodeAAddress = cmd.NodeAAddress;
        site.NodeBAddress = cmd.NodeBAddress;
        site.GrpcNodeAAddress = cmd.GrpcNodeAAddress;
        site.GrpcNodeBAddress = cmd.GrpcNodeBAddress;
        await repo.UpdateSiteAsync(site);
        await repo.SaveChangesAsync();
        var commService = sp.GetService<CommunicationService>();
        commService?.RefreshSiteAddresses();
        await AuditAsync(sp, user, "Update", "Site", site.Id.ToString(), site.Name, site);
        return site;
    }

    private static async Task<object?> HandleDeleteSite(IServiceProvider sp, DeleteSiteCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var site = await repo.GetSiteByIdAsync(cmd.SiteId);
        var instances = await repo.GetInstancesBySiteIdAsync(cmd.SiteId);
        if (instances.Count > 0)
            throw new ManagementCommandException(
                $"Cannot delete site {cmd.SiteId}: it has {instances.Count} instance(s).");
        await repo.DeleteSiteAsync(cmd.SiteId);
        await repo.SaveChangesAsync();
        var commService = sp.GetService<CommunicationService>();
        commService?.RefreshSiteAddresses();

        // Dispose the deleted site's cached gRPC channels — RemoveSiteAsync is the
        // factory's designed site-deletion disposal path and previously had no
        // production caller (arch review 02 round 2, N8): a deleted site's channels
        // (both node endpoints) otherwise persist until process restart. Null-safe:
        // test/composition roots without the factory skip it. Any live-alarm
        // aggregator for the site reconciles to an empty snapshot and is reaped by
        // the viewer linger (documented acceptance — see Component-Communication.md).
        var grpcFactory = sp.GetService<ZB.MOM.WW.ScadaBridge.Communication.Grpc.SiteStreamGrpcClientFactory>();
        if (grpcFactory is not null && site is not null)
            await grpcFactory.RemoveSiteAsync(site.SiteIdentifier);

        await AuditAsync(sp, user, "Delete", "Site", cmd.SiteId.ToString(), site?.Name ?? cmd.SiteId.ToString(), null);
        return true;
    }

    private static async Task<object?> HandleListAreas(IServiceProvider sp, ListAreasCommand cmd, AuthenticatedUser user)
    {
        EnforceSiteScope(user, cmd.SiteId);
        var repo = sp.GetRequiredService<ICentralUiRepository>();
        return await repo.GetAreaTreeBySiteIdAsync(cmd.SiteId);
    }

    private static async Task<object?> HandleCreateArea(IServiceProvider sp, CreateAreaCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var area = new Area(cmd.Name)
        {
            SiteId = cmd.SiteId,
            ParentAreaId = cmd.ParentAreaId
        };
        await repo.AddAreaAsync(area);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Create", "Area", area.Id.ToString(), area.Name, area);
        return area;
    }

    private static async Task<object?> HandleDeleteArea(IServiceProvider sp, DeleteAreaCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        await repo.DeleteAreaAsync(cmd.AreaId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "Area", cmd.AreaId.ToString(), cmd.AreaId.ToString(), null);
        return true;
    }

    // ========================================================================
    // Data Connection handlers
    // ========================================================================

    /// <summary>
    /// Secret-elided projection of a DataConnection (arch-review C3): the raw
    /// PrimaryConfiguration/BackupConfiguration JSON can embed OPC UA user and
    /// certificate passwords, and List/Get are readable by any authenticated user.
    /// Mirrors <see cref="SmtpConfigPublicShape"/>. The secret-bearing config blobs
    /// are scrubbed to the redaction sentinel; every non-config field is projected
    /// verbatim so callers keep the full shape they had before.
    /// </summary>
    private static object DataConnectionPublicShape(DataConnection c)
    {
        var (primary, s1) = ConfigSecretScrubber.Scrub(c.PrimaryConfiguration);
        var (backup, s2) = ConfigSecretScrubber.Scrub(c.BackupConfiguration);
        return new
        {
            c.Id,
            c.SiteId,
            c.Name,
            c.Protocol,
            c.FailoverRetryCount,
            PrimaryConfiguration = primary,
            BackupConfiguration = backup,
            HasSecrets = s1 || s2,
        };
    }

    private static async Task<object?> HandleListDataConnections(IServiceProvider sp, ListDataConnectionsCommand cmd)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        if (cmd.SiteId.HasValue)
            return (await repo.GetDataConnectionsBySiteIdAsync(cmd.SiteId.Value)).Select(DataConnectionPublicShape).ToList();
        return (await repo.GetAllDataConnectionsAsync()).Select(DataConnectionPublicShape).ToList();
    }

    private static async Task<object?> HandleGetDataConnection(IServiceProvider sp, GetDataConnectionCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId);
        if (conn != null)
            EnforceSiteScope(user, conn.SiteId);
        return conn is null ? null : DataConnectionPublicShape(conn);
    }

    private static async Task<object?> HandleCreateDataConnection(IServiceProvider sp, CreateDataConnectionCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var conn = new DataConnection(cmd.Name, cmd.Protocol, cmd.SiteId)
        {
            PrimaryConfiguration = cmd.PrimaryConfiguration,
            BackupConfiguration = cmd.BackupConfiguration,
            FailoverRetryCount = cmd.FailoverRetryCount
        };
        await repo.AddDataConnectionAsync(conn);
        await repo.SaveChangesAsync();
        var publicShape = DataConnectionPublicShape(conn);
        await AuditAsync(sp, user, "Create", "DataConnection", conn.Id.ToString(), conn.Name, publicShape);
        return publicShape;
    }

    private static async Task<object?> HandleUpdateDataConnection(IServiceProvider sp, UpdateDataConnectionCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
            ?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");
        conn.Name = cmd.Name;
        conn.Protocol = cmd.Protocol;
        // Sentinel-preserving merge: a client that round-trips a scrubbed config
        // (secrets replaced with the redaction sentinel) keeps the stored secret
        // rather than wiping it. Non-sentinel values overwrite as usual.
        conn.PrimaryConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.PrimaryConfiguration, conn.PrimaryConfiguration);
        conn.BackupConfiguration = ConfigSecretScrubber.MergeSentinels(cmd.BackupConfiguration, conn.BackupConfiguration);
        conn.FailoverRetryCount = cmd.FailoverRetryCount;
        await repo.UpdateDataConnectionAsync(conn);
        await repo.SaveChangesAsync();
        var publicShape = DataConnectionPublicShape(conn);
        await AuditAsync(sp, user, "Update", "DataConnection", conn.Id.ToString(), conn.Name, publicShape);
        return publicShape;
    }

    private static async Task<object?> HandleDeleteDataConnection(IServiceProvider sp, DeleteDataConnectionCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();
        await repo.DeleteDataConnectionAsync(cmd.DataConnectionId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "DataConnection", cmd.DataConnectionId.ToString(), cmd.DataConnectionId.ToString(), null);
        return true;
    }

    /// <summary>
    /// Moves a data connection to another site. High-risk + data
    /// integrity: every guard runs server-side BEFORE the write, and when in
    /// doubt the move is BLOCKED with a clear error rather than risking an
    /// orphaned binding/reference. No bindings or name references are rewritten.
    /// </summary>
    private static async Task<object?> HandleMoveDataConnection(IServiceProvider sp, MoveDataConnectionCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISiteRepository>();

        var conn = await repo.GetDataConnectionByIdAsync(cmd.DataConnectionId)
            ?? throw new ManagementCommandException($"DataConnection with ID {cmd.DataConnectionId} not found.");

        var sourceSiteId = conn.SiteId;
        if (sourceSiteId == cmd.TargetSiteId)
            throw new ManagementCommandException(
                $"DataConnection '{conn.Name}' (ID {conn.Id}) already belongs to site {cmd.TargetSiteId}.");

        // Guard 1: target site must exist.
        var targetSite = await repo.GetSiteByIdAsync(cmd.TargetSiteId)
            ?? throw new ManagementCommandException($"Target site with ID {cmd.TargetSiteId} not found.");

        // Guard 2: no name collision at the target site.
        var targetConnections = await repo.GetDataConnectionsBySiteIdAsync(cmd.TargetSiteId);
        if (targetConnections.Any(c => c.Id != conn.Id
                && string.Equals(c.Name, conn.Name, StringComparison.OrdinalIgnoreCase)))
        {
            throw new ManagementCommandException(
                $"Target site '{targetSite.Name}' (ID {cmd.TargetSiteId}) already has a data connection named '{conn.Name}'. " +
                "Rename one of them before moving.");
        }

        // Guard 3 (primary data-integrity guard): no InstanceConnectionBinding may
        // reference the connection. Instances are site-scoped, so a bound
        // connection cannot leave its site without orphaning the binding.
        var blockingInstances = await repo.GetInstancesReferencingDataConnectionAsync(conn.Id);
        if (blockingInstances.Count > 0)
        {
            var names = string.Join(", ", blockingInstances
                .OrderBy(i => i.UniqueName, StringComparer.OrdinalIgnoreCase)
                .Select(i => $"'{i.UniqueName}' (ID {i.Id})"));
            throw new ManagementCommandException(
                $"Cannot move data connection '{conn.Name}' (ID {conn.Id}): it is referenced by " +
                $"{blockingInstances.Count} instance binding(s) on its current site. Rebind or delete the " +
                $"following instance(s) first: {names}.");
        }

        // Guard 4 (name-based native-alarm-source references): templates reference
        // a connection by Name (TemplateNativeAlarmSource.ConnectionName) and
        // instances may override that name
        // (InstanceNativeAlarmSourceOverride.ConnectionNameOverride). Moving a
        // connection changes which physical connection that name resolves to on
        // the SOURCE site, so any such reference *on the source site* to the
        // moving name is a blocker. Detect and report only — never auto-rewrite.
        //
        // Scoping: templates are site-agnostic and ConnectionName resolves
        // against the DEPLOYING site's connection pool at flatten time, so the
        // template scan is restricted to templates actually instantiated on the
        // SOURCE site. A template referencing the same connection NAME but only
        // instantiated on another site is NOT affected by this move (that site
        // keeps its own connection of the same name), so a global template scan
        // would be a false positive that over-blocks legitimate moves whenever
        // connection names are reused across sites (the common case).
        //
        // Comparison is StringComparison.Ordinal to match the
        // flattening/deployment pipeline (FlatteningService / FlatteningPipeline
        // resolve connection names case-sensitively) — block exactly the
        // references the runtime would actually fail to resolve.
        var referenceBlockers = new List<string>();

        // Source-site instances with their native-alarm-source overrides eagerly
        // loaded (purpose-built load so the hot-path GetInstancesBySiteIdAsync
        // stays lean). Reused below for both the override scan and to derive the
        // set of templates to scan.
        var sourceInstances = await repo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(sourceSiteId);

        // Only templates instantiated on the SOURCE site can be broken by the move.
        var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
        var sourceTemplateIds = sourceInstances.Select(i => i.TemplateId).Distinct();
        foreach (var templateId in sourceTemplateIds)
        {
            var template = await templateRepo.GetTemplateByIdAsync(templateId);
            if (template is null) continue;
            foreach (var source in template.NativeAlarmSources)
            {
                if (string.Equals(source.ConnectionName, conn.Name, StringComparison.Ordinal))
                {
                    referenceBlockers.Add(
                        $"template '{template.Name}' native-alarm-source '{source.Name}'");
                }
            }
        }

        // Instance-level overrides on the SOURCE site that name this connection
        // would orphan once the connection leaves the site.
        foreach (var instance in sourceInstances)
        {
            foreach (var ovr in instance.NativeAlarmSourceOverrides)
            {
                if (string.Equals(ovr.ConnectionNameOverride, conn.Name, StringComparison.Ordinal))
                {
                    referenceBlockers.Add(
                        $"instance '{instance.UniqueName}' (ID {instance.Id}) native-alarm-source override '{ovr.SourceCanonicalName}'");
                }
            }
        }

        if (referenceBlockers.Count > 0)
        {
            var details = string.Join(", ", referenceBlockers);
            throw new ManagementCommandException(
                $"Cannot move data connection '{conn.Name}' (ID {conn.Id}): its name is referenced by " +
                $"native-alarm-source binding(s) that would be orphaned or made ambiguous by the move. " +
                $"Resolve the following reference(s) first: {details}.");
        }

        // All guards passed: persist the site change and audit.
        conn.SiteId = cmd.TargetSiteId;
        await repo.UpdateDataConnectionAsync(conn);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Move", "DataConnection", conn.Id.ToString(), conn.Name, conn);
        return conn;
    }


    // ========================================================================
    // External System handlers
    // ========================================================================

    /// <summary>
    /// Secret-elided ExternalSystemDefinition projection (arch-review C3): the
    /// opaque <c>AuthConfiguration</c> blob holds API keys / Basic credentials and
    /// must never leave this boundary via responses or audit afterState. Presence
    /// is surfaced as <c>HasAuthConfiguration</c>; every non-secret field is
    /// projected verbatim. Mirrors <see cref="SmtpConfigPublicShape"/>.
    /// </summary>
    private static object ExternalSystemPublicShape(ExternalSystemDefinition d) => new
    {
        d.Id,
        d.Name,
        d.EndpointUrl,
        d.AuthType,
        d.MaxRetries,
        d.RetryDelay,
        d.TimeoutSeconds,
        HasAuthConfiguration = !string.IsNullOrEmpty(d.AuthConfiguration),
    };

    private static async Task<object?> HandleListExternalSystems(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        return (await repo.GetAllExternalSystemsAsync()).Select(ExternalSystemPublicShape).ToList();
    }

    private static async Task<object?> HandleGetExternalSystem(IServiceProvider sp, GetExternalSystemCommand cmd)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId);
        return def is null ? null : ExternalSystemPublicShape(def);
    }

    private static async Task<object?> HandleCreateExternalSystem(IServiceProvider sp, CreateExternalSystemCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var def = new ExternalSystemDefinition(cmd.Name, cmd.EndpointUrl, cmd.AuthType)
        {
            AuthConfiguration = cmd.AuthConfiguration,
            TimeoutSeconds = cmd.TimeoutSeconds ?? 0
        };
        await repo.AddExternalSystemAsync(def);
        await repo.SaveChangesAsync();
        var publicShape = ExternalSystemPublicShape(def);
        await AuditAsync(sp, user, "Create", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
        return publicShape;
    }

    private static async Task<object?> HandleUpdateExternalSystem(IServiceProvider sp, UpdateExternalSystemCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var def = await repo.GetExternalSystemByIdAsync(cmd.ExternalSystemId)
            ?? throw new ManagementCommandException($"ExternalSystem with ID {cmd.ExternalSystemId} not found.");
        def.Name = cmd.Name;
        def.EndpointUrl = cmd.EndpointUrl;
        def.AuthType = cmd.AuthType;
        // Preserve-if-null: an update that omits AuthConfiguration leaves the stored
        // secret intact (mirrors the SMTP/SMS credential preserve-if-null rule).
        if (cmd.AuthConfiguration is not null) def.AuthConfiguration = cmd.AuthConfiguration;
        if (cmd.TimeoutSeconds is { } ts) def.TimeoutSeconds = ts;
        await repo.UpdateExternalSystemAsync(def);
        await repo.SaveChangesAsync();
        var publicShape = ExternalSystemPublicShape(def);
        await AuditAsync(sp, user, "Update", "ExternalSystem", def.Id.ToString(), def.Name, publicShape);
        return publicShape;
    }

    private static async Task<object?> HandleDeleteExternalSystem(IServiceProvider sp, DeleteExternalSystemCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        await repo.DeleteExternalSystemAsync(cmd.ExternalSystemId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "ExternalSystem", cmd.ExternalSystemId.ToString(), cmd.ExternalSystemId.ToString(), null);
        return true;
    }

    private static async Task<object?> HandleListExternalSystemMethods(IServiceProvider sp, ListExternalSystemMethodsCommand cmd)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        return await repo.GetMethodsByExternalSystemIdAsync(cmd.ExternalSystemId);
    }

    private static async Task<object?> HandleGetExternalSystemMethod(IServiceProvider sp, GetExternalSystemMethodCommand cmd)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        return await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId);
    }

    private static async Task<object?> HandleCreateExternalSystemMethod(IServiceProvider sp, CreateExternalSystemMethodCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var method = new ExternalSystemMethod(cmd.Name, cmd.HttpMethod, cmd.Path)
        {
            ExternalSystemDefinitionId = cmd.ExternalSystemId,
            ParameterDefinitions = cmd.ParameterDefinitions,
            ReturnDefinition = cmd.ReturnDefinition
        };
        await repo.AddExternalSystemMethodAsync(method);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Create", "ExternalSystemMethod", method.Id.ToString(), method.Name, method);
        return method;
    }

    private static async Task<object?> HandleUpdateExternalSystemMethod(IServiceProvider sp, UpdateExternalSystemMethodCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var method = await repo.GetExternalSystemMethodByIdAsync(cmd.MethodId)
            ?? throw new ManagementCommandException($"ExternalSystemMethod with ID {cmd.MethodId} not found.");
        if (cmd.Name != null) method.Name = cmd.Name;
        if (cmd.HttpMethod != null) method.HttpMethod = cmd.HttpMethod;
        if (cmd.Path != null) method.Path = cmd.Path;
        if (cmd.ParameterDefinitions != null) method.ParameterDefinitions = cmd.ParameterDefinitions;
        if (cmd.ReturnDefinition != null) method.ReturnDefinition = cmd.ReturnDefinition;
        await repo.UpdateExternalSystemMethodAsync(method);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Update", "ExternalSystemMethod", method.Id.ToString(), method.Name, method);
        return method;
    }

    private static async Task<object?> HandleDeleteExternalSystemMethod(IServiceProvider sp, DeleteExternalSystemMethodCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        await repo.DeleteExternalSystemMethodAsync(cmd.MethodId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "ExternalSystemMethod", cmd.MethodId.ToString(), cmd.MethodId.ToString(), null);
        return true;
    }

    // ========================================================================
    // Notification handlers
    // ========================================================================

    private static async Task<object?> HandleListNotificationLists(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        return await repo.GetAllNotificationListsAsync();
    }

    private static async Task<object?> HandleGetNotificationList(IServiceProvider sp, GetNotificationListCommand cmd)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        return await repo.GetNotificationListByIdAsync(cmd.NotificationListId);
    }

    private static async Task<object?> HandleCreateNotificationList(IServiceProvider sp, CreateNotificationListCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        var list = new NotificationList(cmd.Name) { Type = cmd.Type };
        foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
        {
            list.Recipients.Add(recipient);
        }
        await repo.AddNotificationListAsync(list);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Create", "NotificationList", list.Id.ToString(), list.Name, list);
        return list;
    }

    private static async Task<object?> HandleUpdateNotificationList(IServiceProvider sp, UpdateNotificationListCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        var list = await repo.GetNotificationListByIdAsync(cmd.NotificationListId)
            ?? throw new ManagementCommandException($"NotificationList with ID {cmd.NotificationListId} not found.");
        if (list.Type != cmd.Type)
            throw new ManagementCommandException("A notification list's Type cannot be changed after creation.");
        list.Name = cmd.Name;

        var existingRecipients = await repo.GetRecipientsByListIdAsync(cmd.NotificationListId);
        foreach (var r in existingRecipients)
        {
            await repo.DeleteRecipientAsync(r.Id);
        }

        foreach (var recipient in BuildRecipients(cmd.Type, cmd.RecipientEmails, cmd.RecipientPhones))
        {
            recipient.NotificationListId = cmd.NotificationListId;
            await repo.AddRecipientAsync(recipient);
        }

        await repo.UpdateNotificationListAsync(list);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Update", "NotificationList", list.Id.ToString(), list.Name, list);
        return list;
    }

    private static async Task<object?> HandleDeleteNotificationList(IServiceProvider sp, DeleteNotificationListCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        await repo.DeleteNotificationListAsync(cmd.NotificationListId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "NotificationList", cmd.NotificationListId.ToString(), cmd.NotificationListId.ToString(), null);
        return true;
    }

    /// <summary>
    /// SMS Notifications: build the recipient set for a notification list according
    /// to its delivery channel. Email lists map each <paramref name="recipientEmails"/>
    /// entry to an <see cref="NotificationRecipient.ForEmail"/> recipient; SMS lists map
    /// each <paramref name="recipientPhones"/> entry to an <see cref="NotificationRecipient.ForSms"/>
    /// recipient. The off-channel source list is ignored so an Email list never stores a
    /// phone in EmailAddress (and vice versa).
    /// </summary>
    private static IEnumerable<NotificationRecipient> BuildRecipients(
        NotificationType type, IReadOnlyList<string> recipientEmails, IReadOnlyList<string>? recipientPhones)
    {
        if (type == NotificationType.Sms)
        {
            foreach (var phone in recipientPhones ?? Array.Empty<string>())
            {
                yield return NotificationRecipient.ForSms(phone, phone);
            }
        }
        else
        {
            foreach (var email in recipientEmails)
            {
                yield return NotificationRecipient.ForEmail(email, email);
            }
        }
    }

    /// <summary>
    /// Project an SmtpConfiguration to a credential-free shape so the
    /// stored Credentials (SMTP password / OAuth2 client secret) never leaves this
    /// boundary via response payloads or audit afterState. Mirrors the
    /// ApiKey-projection pattern in HandleListApiKeys.
    /// </summary>
    private static object SmtpConfigPublicShape(Commons.Entities.Notifications.SmtpConfiguration c) =>
        new
        {
            c.Id,
            c.Host,
            c.Port,
            c.AuthType,
            c.FromAddress,
            c.TlsMode,
            c.Transport,
            c.ConnectionTimeoutSeconds,
            c.MaxConcurrentConnections,
            c.MaxRetries,
            c.RetryDelay,
            c.OAuth2Authority,
            c.OAuth2Scope,
            HasCredentials = !string.IsNullOrEmpty(c.Credentials),
        };

    private static async Task<object?> HandleListSmtpConfigs(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        var configs = await repo.GetAllSmtpConfigurationsAsync();
        // Project away the Credentials field — read access to this
        // list is broader than the Admin-only UpdateSmtpConfig path that owns
        // the secret.
        return configs.Select(SmtpConfigPublicShape).ToList();
    }

    private static async Task<object?> HandleUpdateSmtpConfig(IServiceProvider sp, UpdateSmtpConfigCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        var config = await repo.GetSmtpConfigurationByIdAsync(cmd.SmtpConfigId)
            ?? throw new ManagementCommandException($"SmtpConfiguration with ID {cmd.SmtpConfigId} not found.");
        config.Host = cmd.Server;
        config.Port = cmd.Port;
        config.AuthType = cmd.AuthMode;
        config.FromAddress = cmd.FromAddress;
        // Preserve-if-null: an update that omits TlsMode/Credentials leaves the
        // existing values intact (non-breaking for callers that do not send them).
        if (cmd.TlsMode is not null) config.TlsMode = cmd.TlsMode;
        if (cmd.Credentials is not null) config.Credentials = cmd.Credentials;
        // Preserve-if-null likewise for the OAuth2 token-endpoint overrides: an
        // omitted authority/scope leaves the stored value (or its M365 default) intact.
        if (cmd.OAuth2Authority is not null) config.OAuth2Authority = cmd.OAuth2Authority;
        if (cmd.OAuth2Scope is not null) config.OAuth2Scope = cmd.OAuth2Scope;
        // Preserve-if-null for the transport too: an omitted value keeps the stored
        // one (null on every pre-EWS row, which means SMTP).
        if (cmd.Transport is not null) config.Transport = cmd.Transport;
        ValidateSmtpConfigTransportShape(config);
        await repo.UpdateSmtpConfigurationAsync(config);
        await repo.SaveChangesAsync();
        // Audit the credential-free shape — the *fact of* the change
        // (and which non-secret fields hold) is observable; the secret value is
        // not persisted to the audit log where OperationalAuditRoles can read it.
        var publicShape = SmtpConfigPublicShape(config);
        await AuditAsync(sp, user, "Update", "SmtpConfiguration", config.Id.ToString(), config.Host, publicShape);
        return publicShape;
    }

    /// <summary>
    /// Write-path gate for the email transport: validates the EFFECTIVE
    /// post-assignment state of an SmtpConfiguration so an operator cannot persist a
    /// row the delivery adapter would immediately park every notification on. The
    /// adapter re-validates at delivery time and remains the authoritative check —
    /// this is a fail-fast at the point of the mistake, not a second source of truth.
    /// </summary>
    /// <param name="config">The configuration with all command assignments already applied.</param>
    /// <exception cref="ManagementCommandException">The transport is unknown, or the EWS shape rules are violated.</exception>
    private static void ValidateSmtpConfigTransportShape(Commons.Entities.Notifications.SmtpConfiguration config)
    {
        // Mirrors NotificationService's EmailTransportParser, inlined because
        // ManagementService does not (and should not) reference NotificationService
        // just for a two-value parse. Keep the accepted values in step with it.
        var isEws = false;
        var transport = config.Transport;
        if (!string.IsNullOrWhiteSpace(transport))
        {
            switch (transport.Trim().ToLowerInvariant())
            {
                case "smtp":
                    break;
                case "ews":
                    isEws = true;
                    break;
                default:
                    throw new ManagementCommandException(
                        $"Unknown email transport '{transport}'. Expected one of: Smtp, Ews.");
            }
        }

        if (!isEws)
        {
            return;
        }

        // EWS reuses Host as the SOAP endpoint URL, not a mail-server hostname.
        if (!Uri.TryCreate(config.Host, UriKind.Absolute, out var endpoint)
            || !string.Equals(endpoint.Scheme, Uri.UriSchemeHttps, StringComparison.Ordinal))
        {
            throw new ManagementCommandException(
                "EWS transport requires Host to be an absolute https:// EWS endpoint URL "
                + $"(e.g. https://mail.example.com/ews/exchange.asmx); got '{config.Host}'.");
        }

        // The EWS sender authenticates with HTTP Basic only — no OAuth2 flow exists
        // on that path, so an OAuth2 row would park on every send.
        if (!string.Equals(config.AuthType, "basic", StringComparison.OrdinalIgnoreCase))
        {
            throw new ManagementCommandException(
                "EWS transport supports only Basic authentication; set AuthMode to 'basic'.");
        }

        // Same split semantics as the delivery adapter: FIRST colon only, because a
        // domain-qualified user name contains none but a password legitimately may.
        // Without this the common mistake — flipping an existing SMTP row (whose
        // Credentials is a bare password) to Ews — would persist happily and then park
        // every single notification at delivery time.
        var credentialParts = config.Credentials?.Split(':', 2) ?? [];
        if (credentialParts.Length != 2 || credentialParts[0].Length == 0 || credentialParts[1].Length == 0)
        {
            throw new ManagementCommandException(
                "EWS transport requires Credentials in 'username:password' form "
                + "(username may be 'domain\\user').");
        }
    }

    /// <summary>
    /// Project an SmsConfiguration to a credential-free shape so the
    /// stored AuthToken (Twilio Auth Token secret) never leaves this boundary via
    /// response payloads or audit afterState. Mirrors <see cref="SmtpConfigPublicShape"/>.
    /// </summary>
    private static object SmsConfigPublicShape(Commons.Entities.Notifications.SmsConfiguration c) =>
        new
        {
            c.Id,
            c.AccountSid,
            c.FromNumber,
            c.MessagingServiceSid,
            c.ApiBaseUrl,
            c.ConnectionTimeoutSeconds,
            c.MaxRetries,
            c.RetryDelay,
            HasAuthToken = !string.IsNullOrEmpty(c.AuthToken),
        };

    private static async Task<object?> HandleListSmsConfigs(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        var configs = await repo.GetAllSmsConfigurationsAsync();
        // Project away the AuthToken field — read access to this
        // list is broader than the Admin-only UpdateSmsConfig path that owns
        // the secret.
        return configs.Select(SmsConfigPublicShape).ToList();
    }

    private static async Task<object?> HandleUpdateSmsConfig(IServiceProvider sp, UpdateSmsConfigCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<INotificationRepository>();
        var configs = await repo.GetAllSmsConfigurationsAsync();
        var config = configs.FirstOrDefault(c => c.Id == cmd.SmsConfigId)
            ?? throw new ManagementCommandException($"SmsConfiguration with ID {cmd.SmsConfigId} not found.");
        // The AccountSid is interpolated un-escaped into the Twilio request URI
        // (SmsNotificationDeliveryAdapter). Reject any value that is not a well-formed
        // Twilio SID at save time, closing the URI-injection door before it is persisted.
        if (!System.Text.RegularExpressions.Regex.IsMatch(cmd.AccountSid, "^AC[0-9a-fA-F]{32}$"))
            throw new ManagementCommandException("Account SID must match Twilio's format: 'AC' followed by 32 hex characters.");
        config.AccountSid = cmd.AccountSid;
        config.FromNumber = cmd.FromNumber;
        config.MessagingServiceSid = cmd.MessagingServiceSid;
        // Preserve-if-null: an update that omits ApiBaseUrl/AuthToken leaves the
        // existing values intact (non-breaking for callers that do not send them,
        // and so the secret AuthToken survives a config edit that does not rotate it).
        if (cmd.ApiBaseUrl is not null) config.ApiBaseUrl = cmd.ApiBaseUrl;
        // Treat an empty/whitespace AuthToken as "omitted", not "clear".
        // A Twilio Auth Token is always required, so an empty value is never a valid
        // rotation — guarding against IsNullOrEmpty (not just null) stops an accidental
        // `--auth-token ""` from silently wiping the stored secret and 401-ing every
        // subsequent send. (Contrast SmtpConfiguration.Credentials, which MAY be cleared
        // when switching to anonymous SMTP, so that path keeps its null-only guard.)
        if (!string.IsNullOrWhiteSpace(cmd.AuthToken)) config.AuthToken = cmd.AuthToken;
        await repo.UpdateSmsConfigurationAsync(config);
        await repo.SaveChangesAsync();
        // Audit the credential-free shape — the AuthToken secret is
        // not persisted to the audit log where OperationalAuditRoles can read it.
        var publicShape = SmsConfigPublicShape(config);
        await AuditAsync(sp, user, "Update", "SmsConfiguration", config.Id.ToString(), config.AccountSid, publicShape);
        return publicShape;
    }

    // ========================================================================
    // Security handlers
    // ========================================================================

    private static async Task<object?> HandleListRoleMappings(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<ISecurityRepository>();
        return await repo.GetAllMappingsAsync();
    }

    // An LDAP-group mapping's Role is a free string on the wire (CLI/API), so both
    // validate membership AND canonicalize casing at the single server-side write path.
    // The stored Role must be CANONICAL casing (Roles.All): ASP.NET RequireClaim policies
    // compare ordinal case-sensitively while the actor compares OrdinalIgnoreCase, so a
    // row stored as "deployer" passes every actor gate but fails every UI policy — a
    // CLI-works/UI-403s split-brain. Canonicalizing here closes the asymmetry (arch-review
    // C5; closes the deferral formerly documented here). A role outside Roles.All never
    // functioned (no policy or authz check matches it), so rejecting it removes a
    // silent-misconfiguration footgun rather than changing behaviour.
    private static string CanonicalizeMappingRole(string role)
    {
        var canonical = Roles.All.FirstOrDefault(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
        return canonical ?? throw new ManagementCommandException(
            $"Role '{role}' is not a recognized role. Valid roles are: {string.Join(", ", Roles.All)}.");
    }

    private static async Task<object?> HandleCreateRoleMapping(IServiceProvider sp, CreateRoleMappingCommand cmd, string user)
    {
        var role = CanonicalizeMappingRole(cmd.Role);
        var repo = sp.GetRequiredService<ISecurityRepository>();
        var mapping = new LdapGroupMapping(cmd.LdapGroupName, role);
        await repo.AddMappingAsync(mapping);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Create", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
        return mapping;
    }

    private static async Task<object?> HandleUpdateRoleMapping(IServiceProvider sp, UpdateRoleMappingCommand cmd, string user)
    {
        var role = CanonicalizeMappingRole(cmd.Role);
        var repo = sp.GetRequiredService<ISecurityRepository>();
        var mapping = await repo.GetMappingByIdAsync(cmd.MappingId)
            ?? throw new ManagementCommandException($"RoleMapping with ID {cmd.MappingId} not found.");
        mapping.LdapGroupName = cmd.LdapGroupName;
        mapping.Role = role;
        await repo.UpdateMappingAsync(mapping);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Update", "RoleMapping", mapping.Id.ToString(), $"{mapping.LdapGroupName}->{mapping.Role}", mapping);
        return mapping;
    }

    private static async Task<object?> HandleDeleteRoleMapping(IServiceProvider sp, DeleteRoleMappingCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISecurityRepository>();
        await repo.DeleteMappingAsync(cmd.MappingId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "RoleMapping", cmd.MappingId.ToString(), cmd.MappingId.ToString(), null);
        return true;
    }

    private static async Task<object?> HandleListApiKeys(IServiceProvider sp)
    {
        // Inbound-API key re-arch: keys are now managed through the shared
        // IInboundApiKeyAdmin seam (over the ZB.MOM.WW.Auth.ApiKeys library) rather than
        // the SQL Server IInboundApiRepository. The seam projection is hash-free by
        // construction — only identity, status, and method-scopes are returned; the
        // secret is shown once at creation (token) and is never retrievable.
        var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
        var keys = await admin.ListAsync();
        return keys
            .Select(k => new { k.KeyId, k.Name, k.Enabled, k.Methods })
            .ToList();
    }

    private static async Task<object?> HandleCreateApiKey(IServiceProvider sp, CreateApiKeyCommand cmd, string user)
    {
        // Inbound-API key re-arch: the library mints the key, persists only the
        // peppered hash, and assembles the one-time bearer token (sbk_<keyId>_<secret>).
        // The token is shown to the operator only here, in the create response; it cannot
        // be retrieved later. No hash/secret is stored or returned by ScadaBridge.
        if (cmd.Methods is null || cmd.Methods.Count == 0)
            throw new ManagementCommandException("At least one method must be specified for an API key.");

        var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
        var created = await admin.CreateAsync(cmd.Name, cmd.Methods);

        await AuditAsync(sp, user, "Create", "ApiKey", created.KeyId, cmd.Name,
            new { created.KeyId, cmd.Name });

        return new
        {
            created.KeyId,
            cmd.Name,
            created.Token,
        };
    }

    private static async Task<object?> HandleDeleteApiKey(IServiceProvider sp, DeleteApiKeyCommand cmd, string user)
    {
        var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
        var deleted = await admin.DeleteAsync(cmd.KeyId);
        if (!deleted)
            throw new ManagementCommandException($"API key '{cmd.KeyId}' not found.");
        await AuditAsync(sp, user, "Delete", "ApiKey", cmd.KeyId, cmd.KeyId, null);
        return deleted;
    }

    // ========================================================================
    // Deployment handlers
    // ========================================================================

    private static async Task<object?> HandleDeployArtifacts(IServiceProvider sp, MgmtDeployArtifactsCommand cmd, AuthenticatedUser user)
    {
        // A site-scoped Deployer may not trigger a FLEET-WIDE artifact deployment:
        // EnforceSiteScope(null) is a no-op by design, so the fleet-wide case needs
        // its own guard (arch-review C2 authorization gap).
        if (cmd.SiteId is null
            && user.PermittedSiteIds.Length > 0
            && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
        {
            throw new SiteScopeViolationException(
                "Site-scoped users cannot deploy artifacts fleet-wide; specify a target site id.");
        }
        EnforceSiteScope(user, cmd.SiteId);

        var svc = sp.GetRequiredService<ArtifactDeploymentService>();
        var result = cmd.SiteId is int siteId
            ? await svc.DeployToSiteAsync(siteId, user.Username)
            : await svc.DeployToAllSitesAsync(user.Username);
        return result.IsSuccess
            ? result.Value
            : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleQueryDeployments(IServiceProvider sp, QueryDeploymentsCommand cmd, AuthenticatedUser user)
    {
        var repo = sp.GetRequiredService<IDeploymentManagerRepository>();

        // Opportunistic retention sweep for terminal records. Rate-limited
        // process-wide and best-effort inside the service, so it costs this query
        // nothing on all but the first call in a retention interval — and it is
        // deliberately on the LIST path, the operation that degrades when the
        // insert-only DeploymentRecords table is allowed to grow unbounded.
        //
        // GetService, not GetRequiredService: retention maintenance is a
        // side-benefit of this handler, not a precondition of answering the query.
        // A composition root that wires the management surface without the
        // deployment services (as the actor's own tests do) must still be able to
        // list deployments.
        if (sp.GetService<DeploymentService>() is { } deploymentService)
            await deploymentService.TryPurgeTerminalDeploymentRecordsAsync();

        // Page and Status are honoured DB-side. Both arrived on the command
        // contract (and the CLI has been sending --page / --page-size / --status
        // all along) but the handler previously ignored all three and returned the
        // whole table.
        var status = ParseDeploymentStatus(cmd.Status);
        var skip = Math.Max(0, (Math.Max(1, cmd.Page) - 1) * Math.Max(1, cmd.PageSize));
        var take = cmd.PageSize > 0 ? cmd.PageSize : (int?)null;

        // Instance-scoped query: enforce against the target instance's site
        // before reading its deployment history.
        if (cmd.InstanceId.HasValue)
        {
            await EnforceSiteScopeForInstance(sp, user, cmd.InstanceId.Value);
            return await repo.QueryDeploymentSummariesAsync(
                cmd.InstanceId.Value, status, instanceIdScope: null, skip, take);
        }

        // Unfiltered query: a site-scoped Deployment user must only see records
        // for instances at sites within their scope. DeploymentRecord has no
        // SiteId, so the scope is resolved to the set of in-scope instance ids and
        // pushed into the query as an id filter — rather than loading every record
        // AND every instance and intersecting them in memory, which made an
        // unfiltered listing cost two full-table loads regardless of page size.
        if (user.PermittedSiteIds.Length == 0 || user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
            return await repo.QueryDeploymentSummariesAsync(null, status, instanceIdScope: null, skip, take);

        var permittedIds = new HashSet<string>(user.PermittedSiteIds);
        var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();

        var allInstances = await templateRepo.GetAllInstancesAsync();
        var scopedInstanceIds = allInstances
            .Where(i => permittedIds.Contains(i.SiteId.ToString()))
            .Select(i => i.Id)
            .ToArray();

        return await repo.QueryDeploymentSummariesAsync(null, status, scopedInstanceIds, skip, take);
    }

    /// <summary>
    /// Parses the wire <c>Status</c> filter into a <see cref="DeploymentStatus"/>.
    /// An unparseable value is treated as "no filter" rather than an error, which
    /// preserves the previous handler's behaviour of ignoring the field entirely.
    /// </summary>
    private static DeploymentStatus? ParseDeploymentStatus(string? status) =>
        !string.IsNullOrWhiteSpace(status) && Enum.TryParse<DeploymentStatus>(status, ignoreCase: true, out var parsed)
            ? parsed
            : null;

    // ========================================================================
    // Audit Log handler
    // ========================================================================

    private static async Task<object?> HandleQueryAuditLog(IServiceProvider sp, QueryAuditLogCommand cmd)
    {
        var repo = sp.GetRequiredService<ICentralUiRepository>();
        return await repo.GetAuditLogEntriesAsync(
            cmd.User, cmd.EntityType, cmd.Action, cmd.From, cmd.To,
            page: cmd.Page, pageSize: cmd.PageSize);
    }

    // ========================================================================
    // Health handlers
    // ========================================================================

    private static object? HandleGetHealthSummary(IServiceProvider sp)
    {
        var aggregator = sp.GetRequiredService<ICentralHealthAggregator>();
        return aggregator.GetAllSiteStates();
    }

    private static object? HandleGetSiteHealth(IServiceProvider sp, GetSiteHealthCommand cmd)
    {
        var aggregator = sp.GetRequiredService<ICentralHealthAggregator>();
        return aggregator.GetSiteState(cmd.SiteIdentifier);
    }

    // ========================================================================
    // Template member handlers
    // ========================================================================

    private static async Task<object?> HandleAddAttribute(IServiceProvider sp, AddTemplateAttributeCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var dataType = ParseDataType(cmd.DataType);
        var elementType = ParseElementDataType(cmd.ElementDataType);
        ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
        var attr = new TemplateAttribute(cmd.Name)
        {
            DataType = dataType,
            ElementDataType = elementType,
            Value = cmd.Value,
            Description = cmd.Description,
            DataSourceReference = cmd.DataSourceReference,
            IsLocked = cmd.IsLocked
        };
        var result = await svc.AddAttributeAsync(cmd.TemplateId, attr, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleUpdateAttribute(IServiceProvider sp, UpdateTemplateAttributeCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var dataType = ParseDataType(cmd.DataType);
        var elementType = ParseElementDataType(cmd.ElementDataType);
        ValidateAttributeTypes(cmd.Name, dataType, elementType, cmd.Value);
        var attr = new TemplateAttribute(cmd.Name)
        {
            DataType = dataType,
            ElementDataType = elementType,
            Value = cmd.Value,
            Description = cmd.Description,
            DataSourceReference = cmd.DataSourceReference,
            IsLocked = cmd.IsLocked
        };
        var result = await svc.UpdateAttributeAsync(cmd.AttributeId, attr, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    /// <summary>
    /// Parses a required data type token. Throws <see cref="ManagementCommandException"/>
    /// (surfaced to the caller as a curated COMMAND_FAILED message) on an
    /// unrecognised type name, rather than letting <c>Enum.Parse</c>'s
    /// <see cref="ArgumentException"/> be masked as a generic internal error.
    /// </summary>
    private static Commons.Types.Enums.DataType ParseDataType(string? dataType)
    {
        if (!Enum.TryParse<Commons.Types.Enums.DataType>(dataType, ignoreCase: true, out var parsed))
            throw new ManagementCommandException($"Unrecognised data type '{dataType}'.");
        return parsed;
    }

    /// <summary>
    /// Parses an optional element data type token. Returns null when the token is
    /// empty/whitespace; throws <see cref="ManagementCommandException"/> on an
    /// unrecognised type name.
    /// </summary>
    private static Commons.Types.Enums.DataType? ParseElementDataType(string? elementDataType)
    {
        if (string.IsNullOrWhiteSpace(elementDataType)) return null;
        if (!Enum.TryParse<Commons.Types.Enums.DataType>(elementDataType, ignoreCase: true, out var parsed))
            throw new ManagementCommandException($"Unrecognised element type '{elementDataType}'.");
        return parsed;
    }

    /// <summary>
    /// Validates the (DataType, ElementDataType, Value) triple shared by the add
    /// and update attribute handlers. Throws <see cref="ManagementCommandException"/>
    /// on any violation:
    /// <list type="bullet">
    /// <item>List attributes require a valid scalar element type.</item>
    /// <item>Scalar attributes may not carry an element type.</item>
    /// <item>A List default value must decode against the declared element type.</item>
    /// </list>
    /// </summary>
    private static void ValidateAttributeTypes(
        string name, Commons.Types.Enums.DataType dataType, Commons.Types.Enums.DataType? elementType, string? value)
    {
        if (dataType == Commons.Types.Enums.DataType.List)
        {
            if (elementType is null || !Commons.Types.AttributeValueCodec.IsValidElementType(elementType.Value))
                throw new ManagementCommandException(
                    $"List attribute '{name}' requires a valid element type (String, Int32, Float, Double, Boolean, DateTime).");

            if (!string.IsNullOrWhiteSpace(value))
            {
                try
                {
                    Commons.Types.AttributeValueCodec.Decode(value, Commons.Types.Enums.DataType.List, elementType);
                }
                catch (FormatException ex)
                {
                    throw new ManagementCommandException(
                        $"List attribute '{name}' has an invalid list value: {ex.Message}");
                }
            }
        }
        else if (elementType is not null)
        {
            throw new ManagementCommandException($"Attribute '{name}': ElementDataType is only valid on List attributes.");
        }
    }

    private static async Task<object?> HandleDeleteAttribute(IServiceProvider sp, DeleteTemplateAttributeCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.DeleteAttributeAsync(cmd.AttributeId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleAddAlarm(IServiceProvider sp, AddTemplateAlarmCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var alarm = new TemplateAlarm(cmd.Name)
        {
            TriggerType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
            PriorityLevel = cmd.PriorityLevel,
            Description = cmd.Description,
            TriggerConfiguration = cmd.TriggerConfiguration,
            IsLocked = cmd.IsLocked
        };
        var result = await svc.AddAlarmAsync(cmd.TemplateId, alarm, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleUpdateAlarm(IServiceProvider sp, UpdateTemplateAlarmCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var alarm = new TemplateAlarm(cmd.Name)
        {
            TriggerType = Enum.Parse<ZB.MOM.WW.ScadaBridge.Commons.Types.Enums.AlarmTriggerType>(cmd.TriggerType, ignoreCase: true),
            PriorityLevel = cmd.PriorityLevel,
            Description = cmd.Description,
            TriggerConfiguration = cmd.TriggerConfiguration,
            IsLocked = cmd.IsLocked
        };
        var result = await svc.UpdateAlarmAsync(cmd.AlarmId, alarm, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteAlarm(IServiceProvider sp, DeleteTemplateAlarmCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.DeleteAlarmAsync(cmd.AlarmId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    // ── Native alarm source bindings (read-only mirror; repository-direct CRUD) ──

    private static async Task<object?> HandleAddNativeAlarmSource(IServiceProvider sp, AddTemplateNativeAlarmSourceCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var source = new TemplateNativeAlarmSource(cmd.Name)
        {
            TemplateId = cmd.TemplateId,
            ConnectionName = cmd.ConnectionName,
            SourceReference = cmd.SourceReference,
            ConditionFilter = cmd.ConditionFilter,
            Description = cmd.Description,
            IsLocked = cmd.IsLocked
        };
        await repo.AddTemplateNativeAlarmSourceAsync(source);
        await repo.SaveChangesAsync();
        // Propagate the new source to derived descendants. Native-source
        // CRUD lives here (not TemplateService), so call the propagation directly.
        await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(cmd.TemplateId, user);
        return source;
    }

    private static async Task<object?> HandleUpdateNativeAlarmSource(IServiceProvider sp, UpdateTemplateNativeAlarmSourceCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId)
            ?? throw new ManagementCommandException($"Native alarm source {cmd.NativeAlarmSourceId} not found.");
        source.Name = cmd.Name;
        source.ConnectionName = cmd.ConnectionName;
        source.SourceReference = cmd.SourceReference;
        source.ConditionFilter = cmd.ConditionFilter;
        source.Description = cmd.Description;
        source.IsLocked = cmd.IsLocked;
        await repo.UpdateTemplateNativeAlarmSourceAsync(source);
        await repo.SaveChangesAsync();
        // Propagate the changed source to derived descendants.
        await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
        return source;
    }

    private static async Task<object?> HandleDeleteNativeAlarmSource(IServiceProvider sp, DeleteTemplateNativeAlarmSourceCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        // Capture the owning template before delete so descendants can be reconciled.
        var source = await repo.GetTemplateNativeAlarmSourceByIdAsync(cmd.NativeAlarmSourceId);
        await repo.DeleteTemplateNativeAlarmSourceAsync(cmd.NativeAlarmSourceId);
        await repo.SaveChangesAsync();
        // Remove the now-orphaned inherited placeholder from derived descendants.
        if (source != null)
            await sp.GetRequiredService<TemplateService>().ReconcileDescendantsAsync(source.TemplateId, user);
        return cmd.NativeAlarmSourceId;
    }

    private static async Task<object?> HandleListNativeAlarmSources(IServiceProvider sp, ListTemplateNativeAlarmSourcesCommand cmd)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        return await repo.GetNativeAlarmSourcesByTemplateIdAsync(cmd.TemplateId);
    }

    private static async Task<object?> HandleAddScript(IServiceProvider sp, AddTemplateScriptCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var script = new TemplateScript(cmd.Name, cmd.Code)
        {
            TriggerType = cmd.TriggerType,
            TriggerConfiguration = cmd.TriggerConfiguration,
            IsLocked = cmd.IsLocked,
            ParameterDefinitions = cmd.ParameterDefinitions,
            ReturnDefinition = cmd.ReturnDefinition,
            MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
            ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds is > 0 ? cmd.ExecutionTimeoutSeconds : null
        };
        var result = await svc.AddScriptAsync(cmd.TemplateId, script, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleUpdateScript(IServiceProvider sp, UpdateTemplateScriptCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var script = new TemplateScript(cmd.Name, cmd.Code)
        {
            TriggerType = cmd.TriggerType,
            TriggerConfiguration = cmd.TriggerConfiguration,
            IsLocked = cmd.IsLocked,
            ParameterDefinitions = cmd.ParameterDefinitions,
            ReturnDefinition = cmd.ReturnDefinition,
            MinTimeBetweenRuns = cmd.MinTimeBetweenRuns,
            ExecutionTimeoutSeconds = cmd.ExecutionTimeoutSeconds is > 0 ? cmd.ExecutionTimeoutSeconds : null
        };
        var result = await svc.UpdateScriptAsync(cmd.ScriptId, script, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteScript(IServiceProvider sp, DeleteTemplateScriptCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.DeleteScriptAsync(cmd.ScriptId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleAddComposition(IServiceProvider sp, AddTemplateCompositionCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.AddCompositionAsync(cmd.TemplateId, cmd.ComposedTemplateId, cmd.InstanceName, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteComposition(IServiceProvider sp, DeleteTemplateCompositionCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<TemplateService>();
        var result = await svc.DeleteCompositionAsync(cmd.CompositionId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    // ========================================================================
    // Shared Script handlers
    // ========================================================================

    private static async Task<object?> HandleListSharedScripts(IServiceProvider sp)
    {
        var svc = sp.GetRequiredService<SharedScriptService>();
        return await svc.GetAllSharedScriptsAsync();
    }

    private static async Task<object?> HandleGetSharedScript(IServiceProvider sp, GetSharedScriptCommand cmd)
    {
        var svc = sp.GetRequiredService<SharedScriptService>();
        return await svc.GetSharedScriptByIdAsync(cmd.SharedScriptId);
    }

    private static async Task<object?> HandleCreateSharedScript(IServiceProvider sp, CreateSharedScriptCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<SharedScriptService>();
        var result = await svc.CreateSharedScriptAsync(cmd.Name, cmd.Code, cmd.ParameterDefinitions, cmd.ReturnDefinition, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleUpdateSharedScript(IServiceProvider sp, UpdateSharedScriptCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<SharedScriptService>();
        var result = await svc.UpdateSharedScriptAsync(cmd.SharedScriptId, cmd.Code, cmd.ParameterDefinitions, cmd.ReturnDefinition, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    private static async Task<object?> HandleDeleteSharedScript(IServiceProvider sp, DeleteSharedScriptCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<SharedScriptService>();
        var result = await svc.DeleteSharedScriptAsync(cmd.SharedScriptId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    // ========================================================================
    // Schema Library handlers
    //
    // Reusable named JSON-Schema library (SharedSchema + ISharedSchemaRepository).
    // Repo-backed, mirroring the DatabaseConnection handler idiom: direct
    // repository writes plus an explicit AuditAsync after each mutation. The unique
    // Name is enforced with a clear duplicate-name error and the SchemaJson is
    // validated as a parseable JSON Schema (reusing InboundApiSchema.Parse) so a
    // malformed library entry never reaches the $ref resolver or the
    // schema-driven value-entry forms.
    // ========================================================================

    private static async Task<object?> HandleListSharedSchemas(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<ISharedSchemaRepository>();
        return await repo.ListAsync();
    }

    private static async Task<object?> HandleGetSharedSchema(IServiceProvider sp, GetSharedSchemaCommand cmd)
    {
        var repo = sp.GetRequiredService<ISharedSchemaRepository>();
        return await repo.GetByIdAsync(cmd.SharedSchemaId);
    }

    private static async Task<object?> HandleCreateSharedSchema(IServiceProvider sp, CreateSharedSchemaCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISharedSchemaRepository>();
        var name = ValidateSchemaName(cmd.Name);
        ValidateSchemaJson(cmd.SchemaJson);

        var existing = await repo.GetByNameAsync(name);
        if (existing is not null)
        {
            throw new ManagementCommandException($"A schema named '{name}' already exists.");
        }

        var schema = new SharedSchema { Name = name, Scope = cmd.Scope, SchemaJson = cmd.SchemaJson };
        await repo.AddAsync(schema);
        await AuditAsync(sp, user, "Create", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
        return schema;
    }

    private static async Task<object?> HandleUpdateSharedSchema(IServiceProvider sp, UpdateSharedSchemaCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISharedSchemaRepository>();
        var name = ValidateSchemaName(cmd.Name);
        ValidateSchemaJson(cmd.SchemaJson);

        var schema = await repo.GetByIdAsync(cmd.SharedSchemaId)
            ?? throw new ManagementCommandException($"Schema with ID {cmd.SharedSchemaId} not found.");

        // A rename may not collide with a DIFFERENT row's name. The same name on the
        // same row is allowed (a schema-body-only edit keeps its name).
        var byName = await repo.GetByNameAsync(name);
        if (byName is not null && byName.Id != schema.Id)
        {
            throw new ManagementCommandException($"A schema named '{name}' already exists.");
        }

        schema.Name = name;
        schema.Scope = cmd.Scope;
        schema.SchemaJson = cmd.SchemaJson;
        await repo.UpdateAsync(schema);
        await AuditAsync(sp, user, "Update", "SharedSchema", schema.Id.ToString(), schema.Name, new { schema.Id, schema.Name, schema.Scope });
        return schema;
    }

    private static async Task<object?> HandleDeleteSharedSchema(IServiceProvider sp, DeleteSharedSchemaCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISharedSchemaRepository>();
        // Pre-fetch the human-readable name before the row is gone so the audit
        // EntityName records "Address" rather than the numeric id — mirroring the
        // Site delete handler. Falls back to the id when the row is already absent.
        var schema = await repo.GetByIdAsync(cmd.SharedSchemaId);
        await repo.DeleteAsync(cmd.SharedSchemaId);
        await AuditAsync(sp, user, "Delete", "SharedSchema", cmd.SharedSchemaId.ToString(),
            schema?.Name ?? cmd.SharedSchemaId.ToString(), null);
        return true;
    }

    /// <summary>
    /// Validates and trims a library schema name. A non-empty unique name is the
    /// lookup key for the <c>lib:Name</c> <c>$ref</c> resolver, so an empty name is a
    /// caller-safe validation error (surfaced verbatim via
    /// <see cref="ManagementCommandException"/>).
    /// </summary>
    private static string ValidateSchemaName(string? rawName)
    {
        var name = rawName?.Trim();
        if (string.IsNullOrEmpty(name))
        {
            throw new ManagementCommandException("Schema name is required.");
        }

        return name;
    }

    /// <summary>
    /// Validates that <paramref name="schemaJson"/> parses as a JSON Schema, reusing the
    /// authoritative <see cref="InboundApiSchema.ParseWithRefs"/> parser so a library
    /// entry obeys the same STRUCTURAL rules as every other stored schema (object form or
    /// legacy flat-array; over-depth and root-scalar inputs rejected). A structural parse
    /// failure is re-thrown as a caller-safe <see cref="ManagementCommandException"/>
    /// naming the fault.
    /// <para>
    /// The <c>ParseWithRefs</c> (collecting) variant is used deliberately so a library
    /// entry that itself carries a <c>{"$ref":"lib:Other"}</c> pointer is NOT rejected at
    /// save time merely because the resolver is not consulted here — a dangling/cyclic
    /// ref surfaces later on the deploy-time / runtime resolution path, exactly
    /// like a forward reference to a not-yet-created entry. Only malformed JSON is a
    /// save-blocking error.
    /// </para>
    /// </summary>
    private static void ValidateSchemaJson(string? schemaJson)
    {
        if (string.IsNullOrWhiteSpace(schemaJson))
        {
            throw new ManagementCommandException("Schema JSON is required.");
        }

        try
        {
            _ = InboundApiSchema.ParseWithRefs(schemaJson, resolveRef: null);
        }
        catch (JsonException ex)
        {
            throw new ManagementCommandException($"Schema JSON is not a valid JSON Schema: {ex.Message}");
        }
    }

    // ========================================================================
    // Database Connection Definition handlers
    // ========================================================================

    /// <summary>
    /// Secret-elided DatabaseConnectionDefinition projection (arch-review C3): the
    /// ADO.NET <c>ConnectionString</c> can embed the SQL password and must never
    /// leave this boundary via List/Get responses. Presence is surfaced as
    /// <c>HasConnectionString</c>. Mirrors <see cref="SmtpConfigPublicShape"/>.
    /// </summary>
    private static object DatabaseConnectionPublicShape(DatabaseConnectionDefinition d) => new
    {
        d.Id,
        d.Name,
        HasConnectionString = !string.IsNullOrEmpty(d.ConnectionString),
    };

    private static async Task<object?> HandleListDatabaseConnections(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        return (await repo.GetAllDatabaseConnectionsAsync()).Select(DatabaseConnectionPublicShape).ToList();
    }

    private static async Task<object?> HandleGetDatabaseConnection(IServiceProvider sp, GetDatabaseConnectionCommand cmd)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId);
        return def is null ? null : DatabaseConnectionPublicShape(def);
    }

    private static async Task<object?> HandleCreateDatabaseConnection(IServiceProvider sp, CreateDatabaseConnectionDefCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var def = new DatabaseConnectionDefinition(cmd.Name, cmd.ConnectionString);
        await repo.AddDatabaseConnectionAsync(def);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Create", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
        return DatabaseConnectionPublicShape(def);
    }

    private static async Task<object?> HandleUpdateDatabaseConnection(IServiceProvider sp, UpdateDatabaseConnectionDefCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        var def = await repo.GetDatabaseConnectionByIdAsync(cmd.DatabaseConnectionId)
            ?? throw new ManagementCommandException($"DatabaseConnection with ID {cmd.DatabaseConnectionId} not found.");
        def.Name = cmd.Name;
        // Preserve-if-null: an update that omits ConnectionString leaves the stored
        // secret intact — the connection string is elided from List/Get, so a
        // round-trip must not be required to re-send it.
        if (cmd.ConnectionString is not null) def.ConnectionString = cmd.ConnectionString;
        await repo.UpdateDatabaseConnectionAsync(def);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Update", "DatabaseConnection", def.Id.ToString(), def.Name, new { def.Id, def.Name });
        return DatabaseConnectionPublicShape(def);
    }

    private static async Task<object?> HandleDeleteDatabaseConnection(IServiceProvider sp, DeleteDatabaseConnectionDefCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IExternalSystemRepository>();
        await repo.DeleteDatabaseConnectionAsync(cmd.DatabaseConnectionId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "DatabaseConnection", cmd.DatabaseConnectionId.ToString(), cmd.DatabaseConnectionId.ToString(), null);
        return true;
    }

    // ========================================================================
    // Inbound API Method handlers
    // ========================================================================

    private static async Task<object?> HandleListApiMethods(IServiceProvider sp)
    {
        var repo = sp.GetRequiredService<IInboundApiRepository>();
        return await repo.GetAllApiMethodsAsync();
    }

    private static async Task<object?> HandleGetApiMethod(IServiceProvider sp, GetApiMethodCommand cmd)
    {
        var repo = sp.GetRequiredService<IInboundApiRepository>();
        return await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
    }

    private static async Task<object?> HandleCreateApiMethod(IServiceProvider sp, CreateApiMethodCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IInboundApiRepository>();
        var method = new ApiMethod(cmd.Name, cmd.Script)
        {
            TimeoutSeconds = cmd.TimeoutSeconds,
            ParameterDefinitions = cmd.ParameterDefinitions,
            ReturnDefinition = cmd.ReturnDefinition
        };
        await repo.AddApiMethodAsync(method);
        await repo.SaveChangesAsync();
        // The script is persisted regardless; surface a non-fatal warning when it does
        // not compile so the caller is not misled into thinking a broken method is live
        // (a never-compiled method returns "Script compilation failed" on first call).
        var compileWarning = TryCompileAndDescribe(sp, method, isUpdate: false);
        await AuditAsync(sp, user, "Create", "ApiMethod", method.Id.ToString(), method.Name, method);
        return ApiMethodResult(method, compileWarning);
    }

    private static async Task<object?> HandleUpdateApiMethod(IServiceProvider sp, UpdateApiMethodCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IInboundApiRepository>();
        var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId)
            ?? throw new ManagementCommandException($"ApiMethod with ID {cmd.ApiMethodId} not found.");
        method.Script = cmd.Script;
        method.TimeoutSeconds = cmd.TimeoutSeconds;
        method.ParameterDefinitions = cmd.ParameterDefinitions;
        method.ReturnDefinition = cmd.ReturnDefinition;
        await repo.UpdateApiMethodAsync(method);
        await repo.SaveChangesAsync();
        // The persisted DB row is authoritative: on a compile failure the method returns
        // a compilation error on every request (ExecuteAsync revision-checks the cached
        // handler against the fresh row), so the saved-but-broken script does NOT keep a
        // working version serving. Surface that as a warning rather than a silent success
        // (the change still persists either way).
        var compileWarning = TryCompileAndDescribe(sp, method, isUpdate: true);
        await AuditAsync(sp, user, "Update", "ApiMethod", method.Id.ToString(), method.Name, method);
        return ApiMethodResult(method, compileWarning);
    }

    private static async Task<object?> HandleDeleteApiMethod(IServiceProvider sp, DeleteApiMethodCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<IInboundApiRepository>();
        var method = await repo.GetApiMethodByIdAsync(cmd.ApiMethodId);
        await repo.DeleteApiMethodAsync(cmd.ApiMethodId);
        await repo.SaveChangesAsync();
        if (method != null)
            sp.GetService<InboundAPI.InboundScriptExecutor>()?.InvalidateMethod(method.Name);
        await AuditAsync(sp, user, "Delete", "ApiMethod", cmd.ApiMethodId.ToString(), method?.Name ?? cmd.ApiMethodId.ToString(), null);
        return true;
    }

    /// <summary>
    /// Recompiles and re-registers an inbound API method's script after a Create/Update,
    /// returning a non-null human-readable warning when the script did NOT compile. The
    /// save is always persisted; the persisted DB row is authoritative, so on compile
    /// failure the method returns a compilation error on every request (ExecuteAsync
    /// revision-checks the cached handler against the fresh row) rather than a previously
    /// registered handler silently continuing to serve (see
    /// <c>InboundScriptExecutor.CompileAndRegister</c>). Returns <c>null</c> when the
    /// script compiled, or when no executor is registered (e.g. a non-inbound host or a
    /// test double).
    /// </summary>
    private static string? TryCompileAndDescribe(IServiceProvider sp, ApiMethod method, bool isUpdate)
    {
        var executor = sp.GetService<InboundAPI.InboundScriptExecutor>();
        if (executor is null)
            return null;
        if (executor.CompileAndRegister(method, out var errors))
            return null;

        var detail = string.Join("; ", errors);
        return isUpdate
            ? $"Script saved but failed to compile; the method will return a compilation error on every request until a compiling version is saved. Compilation errors: {detail}"
            : $"Script saved but failed to compile; the method will return a compilation error until a compiling version is saved. Compilation errors: {detail}";
    }

    /// <summary>
    /// Projects an <see cref="ApiMethod"/> to the Create/Update response shape, adding a
    /// top-level <c>compileWarning</c> (null when the script compiled). Kept as a flat
    /// projection so the method fields stay top-level for the CLI/Central UI and the
    /// persistence-ignorant POCO entity is not polluted with a transient management field.
    /// </summary>
    private static object ApiMethodResult(ApiMethod method, string? compileWarning) => new
    {
        method.Id,
        method.Name,
        method.Script,
        method.ParameterDefinitions,
        method.ReturnDefinition,
        method.TimeoutSeconds,
        compileWarning,
    };

    // ========================================================================
    // Additional Security handlers (API key update, scope rules)
    // ========================================================================

    private static async Task<object?> HandleUpdateApiKey(IServiceProvider sp, UpdateApiKeyCommand cmd, string user)
    {
        // Inbound-API key re-arch: enable/disable via the shared seam (no secret change).
        var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
        var updated = await admin.SetEnabledAsync(cmd.KeyId, cmd.IsEnabled);
        if (!updated)
            throw new ManagementCommandException($"API key '{cmd.KeyId}' not found.");
        await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId,
            new { cmd.KeyId, cmd.IsEnabled });
        return new { cmd.KeyId, cmd.IsEnabled };
    }

    private static async Task<object?> HandleSetApiKeyMethods(IServiceProvider sp, SetApiKeyMethodsCommand cmd, string user)
    {
        // Inbound-API key re-arch: replace a key's method-scope set via the shared seam
        // (no secret change). The library is authoritative for the scope replacement.
        if (cmd.Methods is null || cmd.Methods.Count == 0)
            throw new ManagementCommandException("At least one method must be specified for an API key.");

        var admin = sp.GetRequiredService<IInboundApiKeyAdmin>();
        var updated = await admin.SetMethodsAsync(cmd.KeyId, cmd.Methods);
        if (!updated)
            throw new ManagementCommandException($"API key '{cmd.KeyId}' not found.");
        await AuditAsync(sp, user, "Update", "ApiKey", cmd.KeyId, cmd.KeyId,
            new { cmd.KeyId, cmd.Methods });
        return new { cmd.KeyId, Methods = cmd.Methods };
    }

    private static async Task<object?> HandleListScopeRules(IServiceProvider sp, ListScopeRulesCommand cmd)
    {
        var repo = sp.GetRequiredService<ISecurityRepository>();
        return await repo.GetScopeRulesForMappingAsync(cmd.MappingId);
    }

    private static async Task<object?> HandleAddScopeRule(IServiceProvider sp, AddScopeRuleCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISecurityRepository>();
        var rule = new SiteScopeRule { LdapGroupMappingId = cmd.MappingId, SiteId = cmd.SiteId };
        await repo.AddScopeRuleAsync(rule);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Create", "ScopeRule", rule.Id.ToString(), $"Mapping:{cmd.MappingId}/Site:{cmd.SiteId}", rule);
        return rule;
    }

    private static async Task<object?> HandleDeleteScopeRule(IServiceProvider sp, DeleteScopeRuleCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ISecurityRepository>();
        await repo.DeleteScopeRuleAsync(cmd.ScopeRuleId);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Delete", "ScopeRule", cmd.ScopeRuleId.ToString(), cmd.ScopeRuleId.ToString(), null);
        return true;
    }

    // ========================================================================
    // Area update handler
    // ========================================================================

    private static async Task<object?> HandleUpdateArea(IServiceProvider sp, UpdateAreaCommand cmd, string user)
    {
        var repo = sp.GetRequiredService<ITemplateEngineRepository>();
        var area = await repo.GetAreaByIdAsync(cmd.AreaId)
            ?? throw new ManagementCommandException($"Area with ID {cmd.AreaId} not found.");
        area.Name = cmd.Name;
        await repo.UpdateAreaAsync(area);
        await repo.SaveChangesAsync();
        await AuditAsync(sp, user, "Update", "Area", area.Id.ToString(), area.Name, area);
        return area;
    }

    /// <summary>
    /// Re-parents an area within its site, delegating every validation rule
    /// (not-found, self-parent, descendant-parent cycle, cross-site parent,
    /// sibling name collision) to <c>AreaService.MoveAreaAsync</c>. A null
    /// <c>NewParentAreaId</c> moves the area to the site root. Failures surface
    /// as the standard curated <see cref="ManagementCommandException"/> failure
    /// response, matching the sibling folder/template move handlers. The service
    /// writes the "Move" audit row itself, so this handler does not audit again.
    /// </summary>
    private static async Task<object?> HandleMoveArea(IServiceProvider sp, MoveAreaCommand cmd, string user)
    {
        var svc = sp.GetRequiredService<AreaService>();
        var result = await svc.MoveAreaAsync(cmd.AreaId, cmd.NewParentAreaId, user);
        return result.IsSuccess ? result.Value : throw new ManagementCommandException(result.Error);
    }

    // ========================================================================
    // Remote Query handlers
    // ========================================================================

    private static async Task<object?> HandleQueryEventLogs(IServiceProvider sp, QueryEventLogsCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
        var commService = sp.GetRequiredService<CommunicationService>();
        var request = new EventLogQueryRequest(
            Guid.NewGuid().ToString("N"),
            cmd.SiteIdentifier,
            cmd.From, cmd.To,
            cmd.EventType, cmd.Severity,
            // Instance unique-name filter — the site event log's instance_id column
            // stores the instance UniqueName (InstanceActor.LogLifecycleEvent passes
            // _instanceUniqueName; EventLogQueryService matches instance_id = $instanceId).
            cmd.InstanceName,
            cmd.Keyword,
            null, // ContinuationToken
            cmd.PageSize,
            DateTimeOffset.UtcNow);
        return await commService.QueryEventLogsAsync(cmd.SiteIdentifier, request);
    }

    // OPC UA / MxGateway design-time remote queries (arch-review C4). These
    // handlers give the management API/CLI parity with the Central UI, which
    // already reaches these site actors directly. SiteIdentifier is REQUIRED —
    // the actor must know which site to route to — and site-scope is enforced
    // before relaying, exactly like HandleQueryEventLogs.

    private static async Task<object?> HandleBrowseNode(IServiceProvider sp, BrowseNodeCommand cmd, AuthenticatedUser user)
    {
        var site = cmd.SiteIdentifier
            ?? throw new ManagementCommandException("SiteIdentifier is required when browsing an address space via the management API.");
        await EnforceSiteScopeForIdentifier(sp, user, site);
        var commService = sp.GetRequiredService<CommunicationService>();
        return await commService.BrowseNodeAsync(site, cmd);
    }

    private static async Task<object?> HandleSearchAddressSpace(IServiceProvider sp, SearchAddressSpaceCommand cmd, AuthenticatedUser user)
    {
        var site = cmd.SiteIdentifier
            ?? throw new ManagementCommandException("SiteIdentifier is required when searching an address space via the management API.");
        await EnforceSiteScopeForIdentifier(sp, user, site);
        var commService = sp.GetRequiredService<CommunicationService>();
        return await commService.SearchAddressSpaceAsync(site, cmd);
    }

    private static async Task<object?> HandleVerifyEndpoint(IServiceProvider sp, VerifyEndpointCommand cmd, AuthenticatedUser user)
    {
        var site = cmd.SiteIdentifier
            ?? throw new ManagementCommandException("SiteIdentifier is required when verifying an endpoint via the management API.");
        await EnforceSiteScopeForIdentifier(sp, user, site);
        var commService = sp.GetRequiredService<CommunicationService>();
        return await commService.VerifyEndpointAsync(site, cmd);
    }

    // Server-certificate trust (arch-review C4). Admin-gated at the role check
    // above. These mutate the site's trusted-peer PKI store — the handler only
    // relays; the site-side broadcast/reconcile across both nodes is owned by
    // the site CertStoreActor (plan 03). SiteIdentifier is REQUIRED so the actor
    // knows which site to route to.

    private static async Task<object?> HandleTrustServerCert(IServiceProvider sp, TrustServerCertCommand cmd, AuthenticatedUser user)
    {
        var site = cmd.SiteIdentifier
            ?? throw new ManagementCommandException("SiteIdentifier is required when trusting a server certificate via the management API.");
        await EnforceSiteScopeForIdentifier(sp, user, site);
        var commService = sp.GetRequiredService<CommunicationService>();
        return await commService.TrustServerCertAsync(site, cmd);
    }

    private static async Task<object?> HandleRemoveServerCert(IServiceProvider sp, RemoveServerCertCommand cmd, AuthenticatedUser user)
    {
        var site = cmd.SiteIdentifier
            ?? throw new ManagementCommandException("SiteIdentifier is required when removing a server certificate via the management API.");
        await EnforceSiteScopeForIdentifier(sp, user, site);
        var commService = sp.GetRequiredService<CommunicationService>();
        return await commService.RemoveServerCertAsync(site, cmd);
    }

    private static async Task<object?> HandleListServerCerts(IServiceProvider sp, ListServerCertsCommand cmd, AuthenticatedUser user)
    {
        var site = cmd.SiteIdentifier
            ?? throw new ManagementCommandException("SiteIdentifier is required when listing server certificates via the management API.");
        await EnforceSiteScopeForIdentifier(sp, user, site);
        var commService = sp.GetRequiredService<CommunicationService>();
        return await commService.ListServerCertsAsync(site, cmd);
    }

    private static async Task<object?> HandleQueryParkedMessages(IServiceProvider sp, QueryParkedMessagesCommand cmd, AuthenticatedUser user)
    {
        await EnforceSiteScopeForIdentifier(sp, user, cmd.SiteIdentifier);
        var commService = sp.GetRequiredService<CommunicationService>();
        var request = new ParkedMessageQueryRequest(
            Guid.NewGuid().ToString("N"),
            cmd.SiteIdentifier,
            cmd.Page,
            cmd.PageSize,
            DateTimeOffset.UtcNow);
        return await commService.QueryParkedMessagesAsync(cmd.SiteIdentifier, request);
    }

    private static async Task<object?> HandleDebugSnapshot(IServiceProvider sp, DebugSnapshotCommand cmd, AuthenticatedUser user)
    {
        var instanceRepo = sp.GetRequiredService<ITemplateEngineRepository>();
        var instance = await instanceRepo.GetInstanceByIdAsync(cmd.InstanceId)
            ?? throw new ManagementCommandException($"Instance {cmd.InstanceId} not found.");

        EnforceSiteScope(user, instance.SiteId);

        var siteRepo = sp.GetRequiredService<ISiteRepository>();
        var site = await siteRepo.GetSiteByIdAsync(instance.SiteId)
            ?? throw new ManagementCommandException($"Site {instance.SiteId} not found.");

        var commService = sp.GetRequiredService<CommunicationService>();
        var request = new DebugSnapshotRequest(instance.UniqueName, Guid.NewGuid().ToString("N"));
        return await commService.RequestDebugSnapshotAsync(site.SiteIdentifier, request);
    }

    // ========================================================================
    // Transport bundle operations
    // ========================================================================

    /// <summary>Single-flight gate for transport bundle commands: a 200 MB import holds
    /// ~4 concurrent whole-artifact copies (base64 string, byte[], MemoryStream, JSON envelope),
    /// so two concurrent imports on one central node are a realistic OOM path (arch-review S4).
    /// Commands queue here instead of running concurrently on the thread pool. Note the actor
    /// pipes these handlers to the sender (HandleEnvelope), so without this gate two envelopes
    /// received back-to-back run their handlers concurrently.</summary>
    private static readonly SemaphoreSlim TransportGate = new(1, 1);

    /// <summary>
    /// Resolves the per-type name lists in <paramref name="cmd"/> against the
    /// repositories, builds an <see cref="ExportSelection"/>, exports the bundle,
    /// and returns the encrypted ZIP as base64.
    /// </summary>
    private static async Task<object?> HandleExportBundle(
        IServiceProvider sp, ExportBundleCommand cmd, string username)
    {
        await TransportGate.WaitAsync();
        try
        {
        var templateRepo = sp.GetRequiredService<ITemplateEngineRepository>();
        var externalRepo = sp.GetRequiredService<IExternalSystemRepository>();
        var notifRepo = sp.GetRequiredService<INotificationRepository>();
        var inboundRepo = sp.GetRequiredService<IInboundApiRepository>();
        var siteRepo = sp.GetRequiredService<ISiteRepository>();

        var templates = await templateRepo.GetAllTemplatesAsync();
        var sharedScripts = await templateRepo.GetAllSharedScriptsAsync();
        var externalSystems = await externalRepo.GetAllExternalSystemsAsync();
        var dbConnections = await externalRepo.GetAllDatabaseConnectionsAsync();
        var notificationLists = await notifRepo.GetAllNotificationListsAsync();
        var smtpConfigs = await notifRepo.GetAllSmtpConfigurationsAsync();
        // SMS: central-only SMS provider configs, mirroring smtpConfigs.
        var smsConfigs = await notifRepo.GetAllSmsConfigurationsAsync();
        // Inbound API keys are not transported between environments; only methods.
        var apiMethods = await inboundRepo.GetAllApiMethodsAsync();
        // Site/instance-scoped selection. Sites match on SiteIdentifier
        // (preferred) or Name; instances match on UniqueName.
        var sites = await siteRepo.GetAllSitesAsync();
        var instances = await templateRepo.GetAllInstancesAsync();

        int[] ResolveIds<T>(IReadOnlyList<T> all, IReadOnlyList<string>? names,
            Func<T, string> getName, Func<T, int> getId, string entityType)
        {
            if (cmd.All) return all.Select(getId).ToArray();
            if (names is null || names.Count == 0) return Array.Empty<int>();
            var nameSet = new HashSet<string>(names, StringComparer.Ordinal);
            var matched = all.Where(e => nameSet.Contains(getName(e))).Select(getId).ToArray();
            var missing = nameSet
                .Except(all.Select(getName), StringComparer.Ordinal)
                .ToArray();
            if (missing.Length > 0)
            {
                throw new ManagementCommandException(
                    $"Unknown {entityType} name(s): {string.Join(", ", missing.OrderBy(n => n, StringComparer.Ordinal))}.");
            }
            return matched;
        }

        // Sites accept EITHER the SiteIdentifier (preferred) or the friendly Name
        // per requested token, so this needs a two-key matcher rather than the
        // single-key ResolveIds above. Under All=true every site is included.
        int[] ResolveSiteIds()
        {
            if (cmd.All) return sites.Select(s => s.Id).ToArray();
            var names = cmd.SiteNames;
            if (names is null || names.Count == 0) return Array.Empty<int>();
            var matchedIds = new List<int>();
            var unmatched = new List<string>();
            foreach (var token in names)
            {
                var site = sites.FirstOrDefault(s =>
                    string.Equals(s.SiteIdentifier, token, StringComparison.Ordinal))
                    ?? sites.FirstOrDefault(s =>
                        string.Equals(s.Name, token, StringComparison.Ordinal));
                if (site is null) unmatched.Add(token);
                else matchedIds.Add(site.Id);
            }
            if (unmatched.Count > 0)
            {
                throw new ManagementCommandException(
                    $"Unknown site identifier/name(s): {string.Join(", ", unmatched.OrderBy(n => n, StringComparer.Ordinal))}.");
            }
            return matchedIds.Distinct().ToArray();
        }

        var selection = new ExportSelection(
            TemplateIds: ResolveIds(templates, cmd.TemplateNames, t => t.Name, t => t.Id, "template"),
            SharedScriptIds: ResolveIds(sharedScripts, cmd.SharedScriptNames, s => s.Name, s => s.Id, "shared script"),
            ExternalSystemIds: ResolveIds(externalSystems, cmd.ExternalSystemNames, e => e.Name, e => e.Id, "external system"),
            DatabaseConnectionIds: ResolveIds(dbConnections, cmd.DatabaseConnectionNames, d => d.Name, d => d.Id, "database connection"),
            NotificationListIds: ResolveIds(notificationLists, cmd.NotificationListNames, n => n.Name, n => n.Id, "notification list"),
            // SmtpConfiguration is keyed by Host (no Name column); the bundle
            // preview row shows the Host value, so the CLI uses Host too.
            SmtpConfigurationIds: ResolveIds(smtpConfigs, cmd.SmtpConfigurationNames, s => s.Host, s => s.Id, "SMTP configuration"),
            ApiMethodIds: ResolveIds(apiMethods, cmd.ApiMethodNames, m => m.Name, m => m.Id, "API method"),
            IncludeDependencies: cmd.IncludeDependencies,
            // Site/instance-scoped selection. Under All=true include every
            // site + instance, mirroring how All includes every template/etc.
            SiteIds: ResolveSiteIds(),
            InstanceIds: ResolveIds(instances, cmd.InstanceNames, i => i.UniqueName, i => i.Id, "instance"),
            // SMS: SmsConfiguration is keyed by AccountSid (no Name column);
            // the bundle preview row shows AccountSid, so the CLI uses AccountSid too.
            SmsConfigurationIds: ResolveIds(smsConfigs, cmd.SmsConfigurationNames, s => s.AccountSid, s => s.Id, "SMS configuration"));

        var exporter = sp.GetRequiredService<IBundleExporter>();
        await using var stream = await exporter.ExportAsync(
            selection, user: username, sourceEnvironment: cmd.SourceEnvironment,
            passphrase: cmd.Passphrase);

            using var ms = new MemoryStream();
            await stream.CopyToAsync(ms);
            // Encode directly off the MemoryStream's backing buffer rather than
            // ms.ToArray() — the latter allocates a second whole-artifact copy.
            // The long-term shape is streaming multipart to the caller (as the
            // audit export path does, AuditEndpoints.cs:183+); deferred for now.
            var base64 = Convert.ToBase64String(ms.GetBuffer().AsSpan(0, (int)ms.Length));
            return new ExportBundleResult(base64, (int)ms.Length);
        }
        finally
        {
            TransportGate.Release();
        }
    }

    /// <summary>
    /// Loads + diffs a base64-encoded bundle and returns the preview rows. Does
    /// not apply anything; callers can use this to gate Apply on the diff shape.
    /// </summary>
    private static async Task<object?> HandlePreviewBundle(
        IServiceProvider sp, PreviewBundleCommand cmd)
    {
        await TransportGate.WaitAsync();
        try
        {
            var importer = sp.GetRequiredService<IBundleImporter>();
            var bytes = DecodeBundle(cmd.Base64Bundle);
            BundleSession session;
            try
            {
                await using var stream = new MemoryStream(bytes);
                session = await importer.LoadAsync(stream, cmd.Passphrase);
            }
            catch (ArgumentException ex)
            {
                throw new ManagementCommandException(ex.Message);
            }
            catch (System.Security.Cryptography.CryptographicException)
            {
                throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
            }

            var preview = await importer.PreviewAsync(session.SessionId);
            var adds = preview.Items.Count(i => i.Kind == ConflictKind.New);
            var mods = preview.Items.Count(i => i.Kind == ConflictKind.Modified);
            var ids = preview.Items.Count(i => i.Kind == ConflictKind.Identical);
            var blocks = preview.Items.Count(i => i.Kind == ConflictKind.Blocker);
            // Surface the required site/connection mappings so an operator
            // (CLI or UI) sees which references need resolving before applying.
            return new PreviewBundleResult(
                preview.Items, adds, mods, ids, blocks,
                preview.RequiredSiteMappings, preview.RequiredConnectionMappings);
        }
        finally
        {
            TransportGate.Release();
        }
    }

    /// <summary>
    /// One-shot import: load + preview + apply with a single global conflict
    /// policy applied to every <see cref="ConflictKind.Modified"/> row. Any
    /// <see cref="ConflictKind.Blocker"/> rows fail the call before Apply.
    /// </summary>
    private static async Task<object?> HandleImportBundle(
        IServiceProvider sp, ImportBundleCommand cmd, string username)
    {
        await TransportGate.WaitAsync();
        try
        {
            var policy = ParseConflictPolicy(cmd.DefaultConflictPolicy);
            var importer = sp.GetRequiredService<IBundleImporter>();
            var bytes = DecodeBundle(cmd.Base64Bundle);

            BundleSession session;
            try
            {
                await using var stream = new MemoryStream(bytes);
                session = await importer.LoadAsync(stream, cmd.Passphrase);
            }
            catch (ArgumentException ex)
            {
                throw new ManagementCommandException(ex.Message);
            }
            catch (System.Security.Cryptography.CryptographicException)
            {
                throw new ManagementCommandException("Wrong passphrase or bundle was tampered.");
            }

            var preview = await importer.PreviewAsync(session.SessionId);

            var blockers = preview.Items.Where(i => i.Kind == ConflictKind.Blocker).ToList();
            if (blockers.Count > 0)
            {
                var details = string.Join("; ",
                    blockers.Select(b => $"{b.Name}: {b.BlockerReason}"));
                throw new ManagementCommandException(
                    $"Bundle has {blockers.Count} blocker(s); import aborted. {details}");
            }

            // Resolve every required site/connection reference into a
            // BundleNameMap. Precedence: an explicit operator spec wins; otherwise
            // fall back to the preview's auto-match; otherwise (no match) create-new
            // ONLY if the create-missing flag is set, else fail with a clear message.
            var nameMap = BuildNameMap(cmd, preview);

            // Dedupe by (EntityType, Name) -- the preview can emit multiple rows per
            // entity (e.g. one row per modified member of a template), but ApplyAsync
            // requires a unique resolution per key. Last-write-wins matches the
            // Central UI's TransportImport.BuildDefaultResolutions behavior. For
            // DataConnection rows the preview item Name is already site-qualified
            // ({site}/{name}), so keying generically by (EntityType, Name) is
            // automatically correct — no bare-connection-name special case needed.
            var renameStamp = DateTime.UtcNow.ToString("yyyyMMdd-HHmmss");
            var resolutionsMap = new Dictionary<(string, string), ImportResolution>();
            foreach (var item in preview.Items)
            {
                var action = item.Kind switch
                {
                    ConflictKind.New => ResolutionAction.Add,
                    ConflictKind.Identical => ResolutionAction.Skip,
                    ConflictKind.Modified => policy,
                    _ => ResolutionAction.Skip,
                };
                var renameTo = (item.Kind == ConflictKind.Modified && policy == ResolutionAction.Rename)
                    ? $"{item.Name}-imported-{renameStamp}"
                    : null;
                resolutionsMap[(item.EntityType, item.Name)] = new ImportResolution(
                    item.EntityType, item.Name, action, renameTo);
            }

            return await importer.ApplyAsync(
                session.SessionId, resolutionsMap.Values.ToList(), username, nameMap: nameMap);
        }
        finally
        {
            TransportGate.Release();
        }
    }

    /// <summary>
    /// Merges the operator-supplied <see cref="SiteMappingSpec"/> /
    /// <see cref="ConnectionMappingSpec"/> lists with the preview's auto-matches
    /// and the create-missing flags into a <see cref="BundleNameMap"/>.
    /// <para>
    /// Per required site reference: an explicit spec wins (target present →
    /// <see cref="MappingAction.MapToExisting"/>; null/blank target →
    /// <see cref="MappingAction.CreateNew"/>); otherwise the preview's
    /// <see cref="RequiredSiteMapping.AutoMatchTargetIdentifier"/> is used when
    /// present (MapToExisting); otherwise CreateNew only when
    /// <see cref="ImportBundleCommand.CreateMissingSites"/> is set, else the
    /// reference is unresolved and the import fails. Connections mirror this with
    /// <see cref="ImportBundleCommand.CreateMissingConnections"/>.
    /// </para>
    /// </summary>
    private static BundleNameMap BuildNameMap(ImportBundleCommand cmd, ImportPreview preview)
    {
        var siteSpecs = (cmd.SiteMappings ?? Array.Empty<SiteMappingSpec>())
            .ToDictionary(s => s.SourceSiteIdentifier, StringComparer.Ordinal);
        var connSpecs = (cmd.ConnectionMappings ?? Array.Empty<ConnectionMappingSpec>())
            .ToDictionary(
                c => (c.SourceSiteIdentifier, c.SourceConnectionName),
                c => c);

        var siteMappings = new List<SiteMapping>();
        var unresolved = new List<string>();
        foreach (var required in preview.RequiredSiteMappings)
        {
            if (siteSpecs.TryGetValue(required.SourceSiteIdentifier, out var spec))
            {
                siteMappings.Add(string.IsNullOrWhiteSpace(spec.TargetSiteIdentifier)
                    ? new SiteMapping(required.SourceSiteIdentifier, MappingAction.CreateNew, null)
                    : new SiteMapping(required.SourceSiteIdentifier, MappingAction.MapToExisting, spec.TargetSiteIdentifier));
            }
            else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetIdentifier))
            {
                siteMappings.Add(new SiteMapping(
                    required.SourceSiteIdentifier, MappingAction.MapToExisting, required.AutoMatchTargetIdentifier));
            }
            else if (cmd.CreateMissingSites)
            {
                siteMappings.Add(new SiteMapping(
                    required.SourceSiteIdentifier, MappingAction.CreateNew, null));
            }
            else
            {
                unresolved.Add($"site '{required.SourceSiteIdentifier}'");
            }
        }

        var connMappings = new List<ConnectionMapping>();
        foreach (var required in preview.RequiredConnectionMappings)
        {
            var key = (required.SourceSiteIdentifier, required.SourceConnectionName);
            if (connSpecs.TryGetValue(key, out var spec))
            {
                connMappings.Add(string.IsNullOrWhiteSpace(spec.TargetConnectionName)
                    ? new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null)
                    : new ConnectionMapping(required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, spec.TargetConnectionName));
            }
            else if (!string.IsNullOrWhiteSpace(required.AutoMatchTargetName))
            {
                connMappings.Add(new ConnectionMapping(
                    required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.MapToExisting, required.AutoMatchTargetName));
            }
            else if (cmd.CreateMissingConnections)
            {
                connMappings.Add(new ConnectionMapping(
                    required.SourceSiteIdentifier, required.SourceConnectionName, MappingAction.CreateNew, null));
            }
            else
            {
                unresolved.Add($"connection '{required.SourceSiteIdentifier}/{required.SourceConnectionName}'");
            }
        }

        if (unresolved.Count > 0)
        {
            throw new ManagementCommandException(
                $"Import has {unresolved.Count} unresolved mapping(s): " +
                $"{string.Join(", ", unresolved.OrderBy(u => u, StringComparer.Ordinal))}. " +
                "Supply --map-site/--map-connection for each, or pass " +
                "--create-missing-sites/--create-missing-connections to create them from the bundle.");
        }

        return new BundleNameMap(siteMappings, connMappings);
    }

    private static byte[] DecodeBundle(string base64)
    {
        if (string.IsNullOrEmpty(base64))
        {
            throw new ManagementCommandException("Bundle payload is empty.");
        }
        try
        {
            return Convert.FromBase64String(base64);
        }
        catch (FormatException)
        {
            throw new ManagementCommandException("Bundle payload is not valid base64.");
        }
    }

    private static ResolutionAction ParseConflictPolicy(string? raw)
    {
        return (raw ?? string.Empty).Trim().ToLowerInvariant() switch
        {
            "skip" => ResolutionAction.Skip,
            "overwrite" => ResolutionAction.Overwrite,
            "rename" => ResolutionAction.Rename,
            _ => throw new ManagementCommandException(
                $"Invalid DefaultConflictPolicy '{raw}'. Use one of: skip, overwrite, rename."),
        };
    }
}

/// <summary>
/// Thrown when a site-scoped user attempts an operation on a site they don't have access to.
/// </summary>
public class SiteScopeViolationException : Exception
{
    /// <summary>Initializes a new instance with the given error message.</summary>
    /// <param name="message">The error message describing the scope violation.</param>
    public SiteScopeViolationException(string message) : base(message) { }
}

/// <summary>
/// Thrown by a command handler to signal a curated, caller-safe failure. Its
/// <see cref="Exception.Message"/> is intended to be
/// surfaced verbatim to the HTTP/CLI caller — e.g. a validation result or a
/// "not found" message. Unanticipated exceptions (database faults, parse errors,
/// null-reference, etc.) must NOT be this type, so that <c>MapFault</c> can return
/// a generic message for them and avoid leaking internal detail.
/// </summary>
public class ManagementCommandException : Exception
{
    /// <summary>Initializes a new instance with the given caller-safe error message.</summary>
    /// <param name="message">The error message to surface verbatim to the HTTP/CLI caller.</param>
    public ManagementCommandException(string message) : base(message) { }
}
