using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using NSubstitute.ExceptionExtensions;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Instances;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Schemas;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Scripts;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Templates;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Flattening;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Templates;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.ManagementService;
using ZB.MOM.WW.ScadaBridge.TemplateEngine;
using ZB.MOM.WW.ScadaBridge.TemplateEngine.Services;

namespace ZB.MOM.WW.ScadaBridge.ManagementService.Tests;

public class ManagementActorTests : TestKit, IDisposable
{
    private readonly ITemplateEngineRepository _templateRepo;
    private readonly IAuditService _auditService;
    private readonly ServiceCollection _services;
    private ArtifactDeploymentService _artifactService = null!;

    public ManagementActorTests()
    {
        _templateRepo = Substitute.For<ITemplateEngineRepository>();
        _auditService = Substitute.For<IAuditService>();

        _services = new ServiceCollection();
        _services.AddScoped(_ => _templateRepo);
        _services.AddScoped(_ => _auditService);
        _services.AddScoped<InstanceService>();
    }

    private IActorRef CreateActor()
    {
        var sp = _services.BuildServiceProvider();
        return Sys.ActorOf(Props.Create(() => new ManagementActor(
            sp, NullLogger<ManagementActor>.Instance)));
    }

    private static ManagementEnvelope Envelope(object command, params string[] roles) =>
        new(new AuthenticatedUser("testuser", "Test User", roles, Array.Empty<string>()),
            command, Guid.NewGuid().ToString("N"));

    private static ManagementEnvelope ScopedEnvelope(object command, string[] permittedSiteIds, params string[] roles) =>
        new(new AuthenticatedUser("scopeduser", "Scoped User", roles, permittedSiteIds),
            command, Guid.NewGuid().ToString("N"));

    /// <summary>
    /// Registers a substitutable <see cref="ArtifactDeploymentService"/> into the
    /// harness DI so DeployArtifacts tests can verify which deploy path was taken
    /// (single-site vs. fleet-wide) without exercising the real cluster comms.
    /// The virtual <c>DeployToSiteAsync</c>/<c>DeployToAllSitesAsync</c> members are
    /// intercepted; all other members keep their real (unused) bodies.
    /// </summary>
    private void RegisterArtifactService()
    {
        var comms = new CommunicationService(
            Options.Create(new CommunicationOptions()),
            NullLogger<CommunicationService>.Instance);
        _artifactService = Substitute.For<ArtifactDeploymentService>(
            Substitute.For<ISiteRepository>(),
            Substitute.For<IDeploymentManagerRepository>(),
            Substitute.For<ITemplateEngineRepository>(),
            Substitute.For<IExternalSystemRepository>(),
            Substitute.For<INotificationRepository>(),
            comms,
            Substitute.For<IAuditService>(),
            Options.Create(new DeploymentManagerOptions()),
            NullLogger<ArtifactDeploymentService>.Instance);
        _services.AddScoped(_ => _artifactService);
    }

    private static Result<ArtifactDeploymentSummary> DeploySummary() =>
        Result<ArtifactDeploymentSummary>.Success(
            new ArtifactDeploymentSummary("deploy-1", Array.Empty<SiteArtifactResult>(), 1, 0));

    void IDisposable.Dispose()
    {
        Shutdown();
    }

    // ========================================================================
    // 1. Authorization test -- admin command with wrong role
    // ========================================================================

    [Fact]
    public void CreateSiteCommand_WithDesignRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", "Desc"), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void CreateSiteCommand_WithNoRoles_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateSiteCommand("Site1", "SITE1", null));

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    // ========================================================================
    // ListSecuredWrites role gate (arch-review UA1): read-only, but the
    // history table exposes process-sensitive tag values, so it is restricted
    // to the two-person Secured Write roles + Administrator (any-of).
    // ========================================================================

    [Fact]
    public void ListSecuredWrites_ViewerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "Viewer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    [Theory]
    [InlineData("Operator")]
    [InlineData("Verifier")]
    [InlineData("Administrator")]
    public void ListSecuredWrites_SecuredWriteRoles_Allowed(string role)
    {
        var securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
        securedWriteRepo.QueryAsync(
                Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<int>(),
                Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromResult<IReadOnlyList<PendingSecuredWrite>>(
                Array.Empty<PendingSecuredWrite>()));
        _services.AddScoped(_ => securedWriteRepo);

        var actor = CreateActor();
        actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), role));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void DeploymentCommand_WithDesignRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateInstanceCommand("Inst1", 1, 1), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Deployer", response.Message);
    }

    [Fact]
    public void QueryAuditLogCommand_WithNoRoles_ReturnsUnauthorized()
    {
        // ManagementService-018: QueryAuditLogCommand used to fall through to the
        // default "any authenticated user" case, allowing a Deployment-only or
        // no-role caller to read the configuration audit log via /management
        // even though /api/audit/query enforces OperationalAuditRoles. The fix
        // gates this legacy command to Admin so the older route is never looser
        // than the new REST endpoint.
        var actor = CreateActor();
        var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25));

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void QueryAuditLogCommand_WithDeploymentRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new QueryAuditLogCommand(null, null, null, null, null, 1, 25), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    // ========================================================================
    // 2. Read-only query passes without special role
    // ========================================================================

    [Fact]
    public void ListTemplatesCommand_WithNoRoles_ReturnsSuccess()
    {
        var templates = new List<Template> { new("Template1") { Id = 1 } };
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(templates);

        var actor = CreateActor();
        var envelope = Envelope(new ListTemplatesCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void ListSitesCommand_WithDesignRole_ReturnsSuccess()
    {
        // ListSitesCommand is read-only, any role should work
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.Site>());
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(new ListSitesCommand(), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    // ========================================================================
    // 3. Template list test -- verify data flows through
    // ========================================================================

    [Fact]
    public void ListTemplatesCommand_ReturnsTemplateData()
    {
        // ListTemplates now reads DB-side row summaries rather than materialising
        // every template's full child graph and paging it in memory.
        _templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns(new List<TemplateSummary>
            {
                new(1, "PumpTemplate", "Pump", null, null, false, null, 0, 0, 0, 0, 0),
                new(2, "ValveTemplate", "Valve", null, null, false, null, 0, 0, 0, 0, 0)
            });

        var actor = CreateActor();
        var envelope = Envelope(new ListTemplatesCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.NotNull(response.JsonData);
        Assert.Contains("PumpTemplate", response.JsonData);
        Assert.Contains("ValveTemplate", response.JsonData);
    }

    // ========================================================================
    // 4. Instance create test
    // ========================================================================

    [Fact]
    public void CreateInstanceCommand_WithDeploymentRole_ReturnsSuccess()
    {
        var createdInstance = new Instance("Pump1") { Id = 42, TemplateId = 1, SiteId = 1 };

        // InstanceService calls GetTemplateByIdAsync to verify template exists
        _templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new Template("PumpTemplate") { Id = 1 });

        // InstanceService calls GetInstanceByUniqueNameAsync to check uniqueness
        _templateRepo.GetInstanceByUniqueNameAsync("Pump1", Arg.Any<CancellationToken>())
            .Returns((Instance?)null);

        // InstanceService calls AddInstanceAsync then SaveChangesAsync
        _templateRepo.AddInstanceAsync(Arg.Any<Instance>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        _templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
            .Returns(1);

        _auditService.LogAsync(
            Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
            Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(),
            Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateInstanceCommand("Pump1", 1, 1),
            "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(response.JsonData);
        Assert.Contains("Pump1", response.JsonData);
    }

    [Fact]
    public void SetNativeAlarmSourceOverride_OnLockedSource_ReturnsManagementError()
    {
        // #05-T13: the ManagementActor must reject an override targeting a source
        // that is locked at the template level, before persisting anything.
        var pipeline = Substitute.For<IFlatteningPipeline>();
        var flattened = new FlattenedConfiguration
        {
            NativeAlarmSources = new List<ResolvedNativeAlarmSource>
            {
                new() { CanonicalName = "Pressure", IsLocked = true, Source = "Template" }
            }
        };
        pipeline.FlattenAndValidateAsync(10, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
        _services.AddScoped(_ => pipeline);

        var actor = CreateActor();
        var envelope = Envelope(
            new SetInstanceNativeAlarmSourceOverrideCommand(10, "Pressure", "OtherOpc", null, null),
            "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
        // The override must NOT be persisted.
        _templateRepo.DidNotReceive().AddInstanceNativeAlarmSourceOverrideAsync(
            Arg.Any<InstanceNativeAlarmSourceOverride>());
    }

    // ========================================================================
    // 5. Error handling test -- service throws exception
    // ========================================================================

    [Fact]
    public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
    {
        _templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidOperationException("Database connection lost"));

        var actor = CreateActor();
        var envelope = Envelope(new ListTemplatesCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // An unanticipated repository fault must NOT leak its raw message to the
        // caller (finding ManagementService-016); a generic message is returned.
        Assert.DoesNotContain("Database connection lost", response.Error);
        Assert.Contains(envelope.CorrelationId, response.Error);
    }

    [Fact]
    public void CreateInstanceCommand_WhenServiceFails_ReturnsManagementError()
    {
        // Template not found -- InstanceService will return Result.Failure
        _templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
            .Returns((Template?)null);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateInstanceCommand("BadInst", 99, 1),
            "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
    }

    // ========================================================================
    // Role boundary tests -- verify each category
    // ========================================================================

    [Fact]
    public void DesignCommand_WithAdminRole_ReturnsUnauthorized()
    {
        // CreateTemplateCommand requires "Designer" role, "Administrator" alone is insufficient
        var actor = CreateActor();
        var envelope = Envelope(
            new CreateTemplateCommand("T1", null, null),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void AdminCommand_WithAdminRole_Succeeds()
    {
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.AddSiteAsync(Arg.Any<Commons.Entities.Sites.Site>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
            .Returns(1);
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateSiteCommand("NewSite", "NS1", "Desc"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void RoleCheck_IsCaseInsensitive()
    {
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.AddSiteAsync(Arg.Any<Commons.Entities.Sites.Site>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>())
            .Returns(1);
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        // "administrator" lowercase should still match "Administrator" requirement
        var envelope = Envelope(
            new CreateSiteCommand("Site2", "S2", null),
            "administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    // ========================================================================
    // New command authorization tests
    // ========================================================================

    [Fact]
    public void SharedScriptCreate_WithAdminRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateSharedScriptCommand("Script1", "code", null, null), "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void DatabaseConnectionCreate_WithDeploymentRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateDatabaseConnectionDefCommand("DB1", "Server=test"), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void ApiMethodCreate_WithAdminRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new CreateApiMethodCommand("Method1", "code", 30, null, null), "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void UpdateApiMethod_NonCompilingScript_SucceedsButReturnsCompileWarning()
    {
        // The save still persists, but a script that does not compile must surface a
        // top-level compileWarning in the result rather than silently looking like a
        // success. The DB row is authoritative: ExecuteAsync revision-checks the cached
        // handler against the fresh row, so the broken script returns a compilation error
        // on every request/node rather than a previously registered handler still serving.
        var existing = new Commons.Entities.InboundApi.ApiMethod("MyMethod", "return 1;")
        {
            Id = 7,
            TimeoutSeconds = 10
        };
        var apiRepo = Substitute.For<IInboundApiRepository>();
        apiRepo.GetApiMethodByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
        apiRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
        _services.AddScoped(_ => apiRepo);
        _services.AddSingleton(sp => new ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptExecutor(
            NullLogger<ZB.MOM.WW.ScadaBridge.InboundAPI.InboundScriptExecutor>.Instance, sp));

        var actor = CreateActor();
        // Undefined symbol -> genuine Roslyn compile error.
        var envelope = Envelope(
            new UpdateApiMethodCommand(7, "return doesNotCompile;", 10, null, null), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(10));
        Assert.Contains("compileWarning", response.JsonData);
        Assert.Contains("failed to compile", response.JsonData);
    }

    [Fact]
    public void AddTemplateAttribute_WithDeploymentRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new AddTemplateAttributeCommand(1, "Attr1", "Float", null, null, null, false), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    // ========================================================================
    // MV-10: ElementDataType accept + validate on attribute add/update
    // ========================================================================

    [Fact]
    public void AddListAttribute_WithStringElementType_PersistsBothColumns()
    {
        // A template exists with no attributes; AddAttributeAsync will save the
        // entity built by the handler. Capture it to assert the persisted shape.
        var template = new Template("T1") { Id = 1 };
        _templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Template> { template });

        TemplateAttribute? saved = null;
        _templateRepo
            .When(r => r.AddTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
            .Do(ci => saved = ci.Arg<TemplateAttribute>());
        _templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
        _services.AddScoped<TemplateService>();

        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\",\"b\"]", null, null, false, "String"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(saved);
        Assert.Equal(Commons.Types.Enums.DataType.List, saved!.DataType);
        Assert.Equal(Commons.Types.Enums.DataType.String, saved.ElementDataType);
        Assert.Equal("[\"a\",\"b\"]", saved.Value);
    }

    [Fact]
    public void AddListAttribute_WithNoElementType_ReturnsManagementError()
    {
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, null),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("requires a valid element type", response.Error);
    }

    [Fact]
    public void AddListAttribute_WithBinaryElementType_ReturnsManagementError()
    {
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Tags", "List", null, null, null, false, "Binary"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("requires a valid element type", response.Error);
    }

    [Fact]
    public void AddScalarAttribute_WithElementType_ReturnsManagementError()
    {
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Count", "Int32", null, null, null, false, "String"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("only valid on List attributes", response.Error);
    }

    [Fact]
    public void AddListAttribute_WithMalformedDefault_ReturnsManagementError()
    {
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Tags", "List", "[\"a\"", null, null, false, "String"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("invalid list value", response.Error);
    }

    [Fact]
    public void AddListAttribute_WithTypeMismatchedDefault_ReturnsManagementError()
    {
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Counts", "List", "[\"x\"]", null, null, false, "Int32"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("invalid list value", response.Error);
    }

    [Fact]
    public void UpdateScalarAttribute_WithElementType_ReturnsManagementError()
    {
        // Update path runs the same pre-service validation: a scalar attribute
        // may not carry an element type, regardless of repository state.
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateTemplateAttributeCommand(5, "Count", "Int32", null, null, null, false, "String"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("only valid on List attributes", response.Error);
    }

    [Fact]
    public void AddAttribute_WithUnrecognisedDataType_ReturnsCuratedManagementError()
    {
        // MV-10 review fix: a bogus DataType string used to throw ArgumentException
        // from Enum.Parse, which MapFault masks as a generic "An internal error
        // occurred" message. TryParse now raises a curated ManagementCommandException
        // whose message names the offending token.
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateAttributeCommand(1, "Whatever", "Nonsense", null, null, null, false, null),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("Unrecognised data type", response.Error);
        Assert.Contains("Nonsense", response.Error);
        Assert.DoesNotContain("internal error", response.Error);
    }

    [Fact]
    public void UpdateAttribute_ValidScalar_PersistsValueAndKeepsFixedFields()
    {
        // MV-10 review fix: documents the fixed-field contract on update —
        // ValidateAttributeOverride(existing, proposed) runs for every update, so
        // a matching DataType/ElementDataType lets the Value change persist while
        // the fixed type columns are never copied from the proposed attribute.
        var template = new Template("T1") { Id = 1 };
        var existing = new TemplateAttribute("Count")
        {
            Id = 5, TemplateId = 1, DataType = Commons.Types.Enums.DataType.Int32,
            ElementDataType = null, Value = "0"
        };
        _templateRepo.GetTemplateAttributeByIdAsync(5, Arg.Any<CancellationToken>()).Returns(existing);
        _templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>()).Returns(template);
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Template> { template });

        TemplateAttribute? updated = null;
        _templateRepo
            .When(r => r.UpdateTemplateAttributeAsync(Arg.Any<TemplateAttribute>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<TemplateAttribute>());
        _templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
        _services.AddScoped<TemplateService>();

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateTemplateAttributeCommand(5, "Count", "Int32", "42", null, null, false, null),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(updated);
        Assert.Equal("42", updated!.Value);                                       // Value updated
        Assert.Equal(Commons.Types.Enums.DataType.Int32, updated.DataType);       // fixed
        Assert.Null(updated.ElementDataType);                                     // fixed
    }

    [Fact]
    public void UpdateApiKey_WithDesignRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new UpdateApiKeyCommand("key-1", true), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void AddScopeRule_WithDesignRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new AddScopeRuleCommand(1, 1), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void UpdateArea_WithAdminRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new UpdateAreaCommand(1, "NewName"), "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    // ========================================================================
    // New command read-only query tests (no role required)
    // ========================================================================

    [Fact]
    public void ListSharedScripts_WithNoRoles_ReturnsSuccess()
    {
        _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Scripts.SharedScript>());
        _services.AddScoped<SharedScriptService>();

        var actor = CreateActor();
        var envelope = Envelope(new ListSharedScriptsCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void ListDatabaseConnections_WithNoRoles_ReturnsSuccess()
    {
        var extRepo = Substitute.For<IExternalSystemRepository>();
        extRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
        _services.AddScoped(_ => extRepo);

        var actor = CreateActor();
        var envelope = Envelope(new ListDatabaseConnectionsCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void ListApiMethods_WithNoRoles_ReturnsSuccess()
    {
        var apiRepo = Substitute.For<IInboundApiRepository>();
        apiRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
        _services.AddScoped(_ => apiRepo);

        var actor = CreateActor();
        var envelope = Envelope(new ListApiMethodsCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void ListScopeRules_WithAdminRole_ReturnsSuccess()
    {
        var secRepo = Substitute.For<ISecurityRepository>();
        secRepo.GetScopeRulesForMappingAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Security.SiteScopeRule>());
        _services.AddScoped(_ => secRepo);

        var actor = CreateActor();
        var envelope = Envelope(new ListScopeRulesCommand(1), "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    // ========================================================================
    // New command error handling tests
    // ========================================================================

    [Fact]
    public void ListDatabaseConnections_WhenRepoThrows_ReturnsError()
    {
        var extRepo = Substitute.For<IExternalSystemRepository>();
        extRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidOperationException("Connection refused"));
        _services.AddScoped(_ => extRepo);

        var actor = CreateActor();
        var envelope = Envelope(new ListDatabaseConnectionsCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // Raw repository fault text is not leaked (finding ManagementService-016).
        Assert.DoesNotContain("Connection refused", response.Error);
        Assert.Contains(envelope.CorrelationId, response.Error);
    }

    // ========================================================================
    // Site-scope enforcement tests (findings ManagementService-001 / -002)
    //
    // A site-scoped Deployment user (PermittedSiteIds set, no Admin role) must
    // not be able to read or act on entities belonging to a site outside their
    // scope. The handlers must throw SiteScopeViolationException, which the
    // actor maps to ManagementUnauthorized.
    // ========================================================================

    private void AddSiteRepoWithSite(int siteId, string identifier)
    {
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetSiteByIdAsync(siteId, Arg.Any<CancellationToken>())
            .Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
        siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any<CancellationToken>())
            .Returns(new Commons.Entities.Sites.Site($"Site{siteId}", identifier) { Id = siteId });
        _services.AddScoped(_ => siteRepo);
    }

    [Fact]
    public void GetInstance_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        // Instance belongs to site 2; user is scoped to site 1.
        _templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
            .Returns(new Instance("Pump7") { Id = 7, SiteId = 2 });

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void GetInstance_InScopeForSiteScopedUser_ReturnsSuccess()
    {
        _templateRepo.GetInstanceByIdAsync(7, Arg.Any<CancellationToken>())
            .Returns(new Instance("Pump7") { Id = 7, SiteId = 1 });

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new GetInstanceCommand(7), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void GetSite_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void ListAreas_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        AddSiteRepoWithSite(2, "SITE2");
        var uiRepo = Substitute.For<ICentralUiRepository>();
        uiRepo.GetAreaTreeBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Instances.Area>());
        _services.AddScoped(_ => uiRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new ListAreasCommand(2), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void GetDataConnection_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(new Commons.Entities.Sites.DataConnection("Conn5", "OpcUa", 2) { Id = 5 });
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new GetDataConnectionCommand(5), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void GetSite_OutOfScopeForAdminUser_ReturnsSuccess()
    {
        // Admin role bypasses site scoping even when PermittedSiteIds is set.
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new GetSiteCommand(2), new[] { "1" }, "Administrator");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void QueryEventLogs_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        // Site SITE2 has Id 2; user scoped to site 1.
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryEventLogsCommand("SITE2"), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryParkedMessagesCommand("SITE2"), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void RetryParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new RetryParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void DiscardParkedMessage_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new DiscardParkedMessageCommand("SITE2", "msg-1"), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    [Fact]
    public void DebugSnapshot_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
    {
        // Instance 9 belongs to site 2; user scoped to site 1.
        _templateRepo.GetInstanceByIdAsync(9, Arg.Any<CancellationToken>())
            .Returns(new Instance("Pump9") { Id = 9, SiteId = 2 });
        AddSiteRepoWithSite(2, "SITE2");

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new DebugSnapshotCommand(9), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
    }

    // ========================================================================
    // Serialization (finding ManagementService-007)
    //
    // Command results are serialized with System.Text.Json configured with
    // ReferenceHandler.IgnoreCycles, so an entity graph with a bidirectional
    // navigation property does not throw. Property names are camelCase, which
    // the CLI's case-insensitive deserializer accepts.
    // ========================================================================

    private sealed class CyclicNode
    {
        public string Name { get; set; } = "";
        public CyclicNode? Parent { get; set; }
        public List<CyclicNode> Children { get; set; } = new();
    }

    [Fact]
    public void SerializeResult_WithCyclicGraph_DoesNotThrow()
    {
        var parent = new CyclicNode { Name = "Parent" };
        var child = new CyclicNode { Name = "Child", Parent = parent };
        parent.Children.Add(child); // parent <-> child cycle

        var json = ManagementActor.SerializeResult(parent);

        Assert.Contains("Parent", json);
        Assert.Contains("Child", json);
    }

    [Fact]
    public void SerializeResult_UsesCamelCasePropertyNames()
    {
        var json = ManagementActor.SerializeResult(new CyclicNode { Name = "X" });

        Assert.Contains("\"name\"", json);
        Assert.DoesNotContain("\"Name\"", json);
    }

    // ========================================================================
    // PipeTo fault mapping (finding ManagementService-004)
    //
    // Command processing is piped back via PipeTo; a fault raised inside
    // DispatchCommand must be mapped to ManagementError by the failure
    // continuation rather than escaping or being silently dropped.
    // ========================================================================

    // ========================================================================
    // ResolveRolesCommand dead-code removal (finding ManagementService-011 / -008)
    //
    // The two-step ResolveRoles + command flow is retired: the HTTP endpoint does
    // LDAP auth and role resolution itself. The actor must no longer dispatch
    // ResolveRolesCommand — a stray in-cluster sender hitting it gets a uniform
    // ManagementError rather than an unauthenticated role-mapping enumeration.
    // ========================================================================

    [Fact]
    public void ResolveRolesCommand_IsNoLongerDispatched_ReturnsManagementError()
    {
        var secRepo = Substitute.For<ISecurityRepository>();
        _services.AddScoped(_ => secRepo);

        var actor = CreateActor();
        var envelope = Envelope(new ResolveRolesCommand(new[] { "cn=admins" }));

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // No role-mapping data is enumerated/leaked back to the caller.
        secRepo.DidNotReceiveWithAnyArgs().GetAllMappingsAsync(default);
    }

    [Fact]
    public void UnknownCommandType_FaultMappedToManagementError()
    {
        // ManagementEnvelope.Command is typed object; an unrecognised payload
        // makes DispatchCommand throw NotSupportedException. The PipeTo failure
        // continuation must map it to ManagementError.
        var actor = CreateActor();
        var envelope = Envelope("not-a-command");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
    }

    // ========================================================================
    // QueryDeployments authorization + site-scope (findings -014 / -017)
    //
    // QueryDeploymentsCommand is gated to the Deployment role and, like every
    // other Deployment-role handler, must enforce site scoping: a site-scoped
    // user must not see deployment records for instances at other sites.
    // ========================================================================

    private static Commons.Entities.Deployment.DeploymentRecord DeploymentRecordFor(int instanceId) =>
        new("deploy-" + instanceId, "operator") { Id = instanceId, InstanceId = instanceId };

    /// <summary>
    /// Row-summary equivalent of <see cref="DeploymentRecordFor"/>. QueryDeployments
    /// now returns DB-side projections instead of whole tracked entities.
    /// </summary>
    private static DeploymentRecordSummary DeploymentSummaryFor(int instanceId) =>
        new(instanceId, "deploy-" + instanceId, instanceId, DeploymentStatus.Success,
            null, "operator", DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, null);

    /// <summary>
    /// Stubs <c>QueryDeploymentSummariesAsync</c> so it applies the same
    /// instance / status / scope / paging predicates the database would.
    ///
    /// <para>
    /// The filtering moved OUT of the handler and INTO the query, so a stub that
    /// ignored the arguments would make these tests pass no matter what the handler
    /// passed down. Honouring them here is what keeps the scope-enforcement
    /// assertions meaningful.
    /// </para>
    /// </summary>
    private static void StubDeploymentSummaryQuery(
        IDeploymentManagerRepository repo,
        IEnumerable<int> instanceIds)
    {
        var rows = instanceIds.Select(DeploymentSummaryFor).ToList();
        repo.QueryDeploymentSummariesAsync(
                Arg.Any<int?>(), Arg.Any<DeploymentStatus?>(), Arg.Any<IReadOnlyCollection<int>?>(),
                Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns(ci =>
            {
                var instanceId = ci.ArgAt<int?>(0);
                var scope = ci.ArgAt<IReadOnlyCollection<int>?>(2);
                var skip = ci.ArgAt<int>(3);
                var take = ci.ArgAt<int?>(4);

                IEnumerable<DeploymentRecordSummary> q = rows;
                if (instanceId.HasValue)
                    q = q.Where(r => r.InstanceId == instanceId.Value);
                if (scope != null)
                    q = q.Where(r => scope.Contains(r.InstanceId));
                q = q.Skip(Math.Max(0, skip));
                if (take is > 0)
                    q = q.Take(take.Value);
                return (IReadOnlyList<DeploymentRecordSummary>)q.ToList();
            });
    }

    /// <summary>
    /// Stubs <c>GetTemplateSummariesAsync</c> over <paramref name="count"/> synthetic
    /// templates, applying the requested Skip/Take exactly as the database would.
    /// </summary>
    private void StubTemplateSummaryPaging(int count)
    {
        var rows = Enumerable.Range(1, count)
            .Select(i => new TemplateSummary(i, $"T-{i:D3}", null, null, null, false, null, 0, 0, 0, 0, 0))
            .ToList();

        _templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .Returns(ci =>
            {
                var skip = ci.ArgAt<int>(0);
                var take = ci.ArgAt<int?>(1);
                IEnumerable<TemplateSummary> q = rows.Skip(Math.Max(0, skip));
                if (take is > 0)
                    q = q.Take(take.Value);
                return (IReadOnlyList<TemplateSummary>)q.ToList();
            });
    }

    [Fact]
    public void QueryDeployments_WithDesignRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new QueryDeploymentsCommand(), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Deployer", response.Message);
    }

    [Fact]
    public void QueryDeployments_UnfilteredWithDeploymentRole_ReturnsAllRecords()
    {
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        StubDeploymentSummaryQuery(deployRepo, [1, 2]);
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = Envelope(new QueryDeploymentsCommand(), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("deploy-1", response.JsonData);
        Assert.Contains("deploy-2", response.JsonData);
    }

    [Fact]
    public void QueryDeployments_FilteredByInstanceId_ReturnsInstanceRecords()
    {
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        StubDeploymentSummaryQuery(deployRepo, [5]);
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = Envelope(new QueryDeploymentsCommand(InstanceId: 5), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("deploy-5", response.JsonData);
    }

    [Fact]
    public void QueryDeployments_FilteredByOutOfScopeInstance_ReturnsUnauthorized()
    {
        // Instance 5 belongs to site 2; user is scoped to site 1.
        _templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(new Instance("Pump5") { Id = 5, SiteId = 2 });
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        // The out-of-scope instance's deployment history must not be queried.
        deployRepo.DidNotReceiveWithAnyArgs().QueryDeploymentSummariesAsync(
            default, default, default, default, default);
    }

    [Fact]
    public void QueryDeployments_FilteredByInScopeInstance_ReturnsRecords()
    {
        _templateRepo.GetInstanceByIdAsync(5, Arg.Any<CancellationToken>())
            .Returns(new Instance("Pump5") { Id = 5, SiteId = 1 });
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        StubDeploymentSummaryQuery(deployRepo, [5]);
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryDeploymentsCommand(InstanceId: 5), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("deploy-5", response.JsonData);
    }

    [Fact]
    public void QueryDeployments_UnfilteredForSiteScopedUser_DropsOutOfScopeRecords()
    {
        // ManagementService-023: the unfiltered branch now bulk-loads instances
        // once via GetAllInstancesAsync (instead of N+1 GetInstanceByIdAsync per
        // distinct InstanceId). Mock the bulk path accordingly.
        _templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Instance>
            {
                new("Pump1") { Id = 1, SiteId = 1 },
                new("Pump2") { Id = 2, SiteId = 2 },
            });
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        StubDeploymentSummaryQuery(deployRepo, [1, 2]);
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("deploy-1", response.JsonData);
        Assert.DoesNotContain("deploy-2", response.JsonData);
        // The per-instance lookup must NOT have been used for the unfiltered
        // branch — that was the N+1 the bulk load replaced.
        _templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
    }

    [Fact]
    public void QueryDeployments_UnfilteredForSiteScopedUser_UsesBulkInstanceLoad_NotPerRecordLookup()
    {
        // ManagementService-023 regression pin: the unfiltered branch must issue
        // GetAllInstancesAsync ONCE and never call GetInstanceByIdAsync, regardless
        // of how many DeploymentRecords reference distinct InstanceIds. Before the
        // fix, three distinct instance ids would have produced three per-instance
        // lookups (textbook N+1).
        _templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Instance>
            {
                new("Pump1") { Id = 1, SiteId = 1 },
                new("Pump2") { Id = 2, SiteId = 2 },
                new("Pump3") { Id = 3, SiteId = 1 },
            });
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        StubDeploymentSummaryQuery(deployRepo, [1, 2, 3]);
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Deployer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _templateRepo.Received(1).GetAllInstancesAsync(Arg.Any<CancellationToken>());
        _templateRepo.DidNotReceiveWithAnyArgs().GetInstanceByIdAsync(default);
    }

    [Fact]
    public void QueryDeployments_UnfilteredForAdminUser_ReturnsAllRecords()
    {
        // Admin role bypasses site scoping even with PermittedSiteIds set.
        // (The user also holds Deployment so it passes the role gate.)
        var deployRepo = Substitute.For<IDeploymentManagerRepository>();
        StubDeploymentSummaryQuery(deployRepo, [1, 2]);
        _services.AddScoped(_ => deployRepo);

        var actor = CreateActor();
        var envelope = ScopedEnvelope(new QueryDeploymentsCommand(), new[] { "1" }, "Administrator", "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("deploy-1", response.JsonData);
        Assert.Contains("deploy-2", response.JsonData);
    }

    // ========================================================================
    // DeployArtifacts honors SiteId + site-scope enforcement (arch-review C2)
    //
    // MgmtDeployArtifactsCommand.SiteId is now honored: a value routes to the
    // single-site deploy path; null routes fleet-wide. A site-scoped (non-Admin)
    // Deployer may not deploy fleet-wide, and may not target a site outside scope.
    // ========================================================================

    [Fact]
    public void DeployArtifacts_WithSiteId_CallsSingleSiteDeploy()
    {
        RegisterArtifactService();
        _artifactService.DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>())
            .Returns(DeploySummary());

        var actor = CreateActor();
        actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: 7), "Deployer"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _artifactService.Received(1).DeployToSiteAsync(7, "testuser", Arg.Any<CancellationToken>());
        _artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default);
    }

    [Fact]
    public void DeployArtifacts_FleetWide_CallsAllSitesDeploy()
    {
        RegisterArtifactService();
        _artifactService.DeployToAllSitesAsync("testuser", Arg.Any<CancellationToken>())
            .Returns(DeploySummary());

        var actor = CreateActor();
        actor.Tell(Envelope(new MgmtDeployArtifactsCommand(SiteId: null), "Deployer"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _artifactService.Received(1).DeployToAllSitesAsync("testuser", Arg.Any<CancellationToken>());
        _artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default);
    }

    [Fact]
    public void DeployArtifacts_SiteScopedUser_OutOfScopeSite_Unauthorized()
    {
        RegisterArtifactService();

        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: 7), new[] { "3" }, "Deployer"));

        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        _artifactService.DidNotReceiveWithAnyArgs().DeployToSiteAsync(default, default!, default);
    }

    [Fact]
    public void DeployArtifacts_SiteScopedUser_FleetWide_Unauthorized()
    {
        RegisterArtifactService();

        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new MgmtDeployArtifactsCommand(SiteId: null), new[] { "3" }, "Deployer"));

        var resp = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("fleet-wide", resp.Message, StringComparison.OrdinalIgnoreCase);
        _artifactService.DidNotReceiveWithAnyArgs().DeployToAllSitesAsync(default!, default);
    }

    // ========================================================================
    // Additive Skip/Take paging on template/instance lists (arch-review P2)
    //
    // Take = null preserves the historical unlimited behaviour. When set, paging
    // applies AFTER any in-memory site-scope filtering, so a scoped user never sees
    // an out-of-scope entity surface into their page window.
    // ========================================================================

    [Fact]
    public void ListTemplates_SkipTake_ReturnsRequestedWindow()
    {
        // Paging moved into the repository (DB-side Skip/Take). The stub applies
        // the same window the database would, so this still pins that the
        // command's Skip/Take actually reach the query — the bug being that the
        // handler used to load everything and slice in memory.
        StubTemplateSummaryPaging(30);

        var actor = CreateActor();
        actor.Tell(Envelope(new ListTemplatesCommand(Skip: 10, Take: 5)));

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // Items 11..15 (1-based) present; the pages on either side absent.
        Assert.Contains("T-011", response.JsonData);
        Assert.Contains("T-015", response.JsonData);
        Assert.DoesNotContain("T-010", response.JsonData);
        Assert.DoesNotContain("T-016", response.JsonData);
    }

    [Fact]
    public void ListTemplates_DefaultTake_ReturnsAll()
    {
        StubTemplateSummaryPaging(30);

        var actor = CreateActor();
        actor.Tell(Envelope(new ListTemplatesCommand())); // Take = null => unlimited

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("T-001", response.JsonData);
        Assert.Contains("T-030", response.JsonData);
    }

    [Fact]
    public void ListInstances_PagingAppliesAfterSiteScopeFilter()
    {
        // Scoped user permitted to site 1 only. The repo returns 3 in-scope (site 1)
        // instances plus 2 out-of-scope (site 2). Take:2 must yield 2 of THEIR
        // instances and never an out-of-scope one.
        var uiRepo = Substitute.For<ICentralUiRepository>();
        uiRepo.GetInstancesFilteredAsync(Arg.Any<int?>(), Arg.Any<int?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new List<Instance>
            {
                new("I-001") { Id = 1, SiteId = 1 },
                new("I-002") { Id = 2, SiteId = 1 },
                new("I-003") { Id = 3, SiteId = 1 },
                new("I-101") { Id = 101, SiteId = 2 },
                new("I-102") { Id = 102, SiteId = 2 },
            });
        _services.AddScoped(_ => uiRepo);

        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new ListInstancesCommand(Take: 2), new[] { "1" }, "Deployer"));

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("I-001", response.JsonData);
        Assert.Contains("I-002", response.JsonData);
        Assert.DoesNotContain("I-003", response.JsonData); // paged out
        Assert.DoesNotContain("I-101", response.JsonData); // out of scope
        Assert.DoesNotContain("I-102", response.JsonData); // out of scope
    }

    // ========================================================================
    // SetInstanceOverrides atomicity (finding -015)
    //
    // A multi-override command must be all-or-nothing: if any requested override
    // is invalid (unknown/locked attribute), the handler must reject the whole
    // command up front WITHOUT persisting any of the overrides.
    // ========================================================================

    [Fact]
    public void SetInstanceOverrides_WithOneInvalidAttribute_PersistsNoOverrides()
    {
        // Instance 3, template 1 with a single valid attribute "Good".
        var instance = new Instance("Pump3") { Id = 3, SiteId = 1, TemplateId = 1 };
        _templateRepo.GetInstanceByIdAsync(3, Arg.Any<CancellationToken>()).Returns(instance);
        _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<TemplateAttribute> { new("Good") { Id = 1, TemplateId = 1 } });
        _templateRepo.GetOverridesByInstanceIdAsync(3, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());

        var actor = CreateActor();
        // "Good" is valid, "Bogus" is not — the whole command must fail with
        // nothing written.
        var overrides = new Dictionary<string, string?> { ["Good"] = "1", ["Bogus"] = "2" };
        var envelope = Envelope(new SetInstanceOverridesCommand(3, overrides), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // No override write occurred for the valid attribute either.
        _templateRepo.DidNotReceiveWithAnyArgs()
            .AddInstanceAttributeOverrideAsync(default!, default);
        _templateRepo.DidNotReceiveWithAnyArgs()
            .UpdateInstanceAttributeOverrideAsync(default!, default);
    }

    [Fact]
    public void SetInstanceOverrides_AllValid_PersistsAllOverrides()
    {
        var instance = new Instance("Pump4") { Id = 4, SiteId = 1, TemplateId = 1 };
        _templateRepo.GetInstanceByIdAsync(4, Arg.Any<CancellationToken>()).Returns(instance);
        _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<TemplateAttribute>
            {
                new("A") { Id = 1, TemplateId = 1 },
                new("B") { Id = 2, TemplateId = 1 }
            });
        _templateRepo.GetOverridesByInstanceIdAsync(4, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Instances.InstanceAttributeOverride>());
        _templateRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);

        var actor = CreateActor();
        var overrides = new Dictionary<string, string?> { ["A"] = "1", ["B"] = "2" };
        var envelope = Envelope(new SetInstanceOverridesCommand(4, overrides), "Deployer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _templateRepo.ReceivedWithAnyArgs(2)
            .AddInstanceAttributeOverrideAsync(default!, default);
    }

    // ========================================================================
    // Unexpected exception messages not leaked to callers (finding -016)
    //
    // MapFault must distinguish handler-curated failures (safe to surface) from
    // unanticipated faults (whose raw .Message can disclose internal detail).
    // ========================================================================

    [Fact]
    public void UnexpectedFault_ReturnsGenericMessage_NotRawExceptionText()
    {
        // Repository throws an unanticipated fault carrying sensitive-looking
        // detail. The raw text must NOT reach the caller.
        const string secret = "Server=db-internal-prod;constraint FK_secret";
        _templateRepo.GetTemplateSummariesAsync(Arg.Any<int>(), Arg.Any<int?>(), Arg.Any<CancellationToken>())
            .ThrowsAsync(new InvalidProgramException(secret));

        var actor = CreateActor();
        var envelope = Envelope(new ListTemplatesCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.DoesNotContain(secret, response.Error);
        // The correlation ID is surfaced so the operator can find the server log.
        Assert.Contains(envelope.CorrelationId, response.Error);
    }

    // ========================================================================
    // UpdateSmtpConfig — TlsMode + Credentials plumbing (preserve-if-null)
    // ========================================================================

    [Fact]
    public void UpdateSmtpConfig_WithTlsModeAndCredentials_PersistsThem()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "OAuth2", "old@example.com")
        {
            Id = 1,
            Port = 25,
            TlsMode = "StartTLS",
            Credentials = "old-secret",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com", "SSL", "user:pass"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("SSL", existing.TlsMode);
        Assert.Equal("user:pass", existing.Credentials);
        Assert.Equal("new.example.com", existing.Host);
        Assert.Equal("Basic", existing.AuthType);
    }

    [Fact]
    public void UpdateSmtpConfig_WithNullTlsModeAndCredentials_PreservesExistingValues()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "OAuth2", "old@example.com")
        {
            Id = 1,
            Port = 25,
            TlsMode = "StartTLS",
            Credentials = "old-secret",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(1, "new.example.com", 465, "Basic", "new@example.com"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        // Omitted fields are preserved, not nulled.
        Assert.Equal("StartTLS", existing.TlsMode);
        Assert.Equal("old-secret", existing.Credentials);
        // Provided fields are still updated.
        Assert.Equal("new.example.com", existing.Host);
        Assert.Equal("Basic", existing.AuthType);
    }

    [Fact]
    public void UpdateSmtpConfig_AppliesOAuth2AuthorityAndScope()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "OAuth2", "old@example.com")
        {
            Id = 1,
            Port = 25,
            OAuth2Authority = "https://old.authority/token",
            OAuth2Scope = "old.scope/.default",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "new.example.com", 465, "OAuth2", "new@example.com",
                OAuth2Authority: "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
                OAuth2Scope: "https://outlook.office365.com/.default"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token", existing.OAuth2Authority);
        Assert.Equal("https://outlook.office365.com/.default", existing.OAuth2Scope);
    }

    [Fact]
    public void UpdateSmtpConfig_WithNullOAuth2AuthorityAndScope_PreservesExistingValues()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "OAuth2", "old@example.com")
        {
            Id = 1,
            Port = 25,
            OAuth2Authority = "https://old.authority/token",
            OAuth2Scope = "old.scope/.default",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(1, "new.example.com", 465, "OAuth2", "new@example.com"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("https://old.authority/token", existing.OAuth2Authority);
        Assert.Equal("old.scope/.default", existing.OAuth2Scope);
    }

    // ========================================================================
    // UpdateSmtpConfig — Transport (EWS) write-path gate
    // ========================================================================

    [Fact]
    public void UpdateSmtpConfig_WithEwsTransport_PersistsTransportAndSurfacesItInPublicShape()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
            Credentials = "example\\notify:fake-password",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
                Transport: "Ews"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("Ews", existing.Transport);
        Assert.Equal("https://ews.example.test/ews/exchange.asmx", existing.Host);
        // The transport is a non-secret field, so it belongs on the public shape
        // the response and audit afterState carry.
        Assert.Contains("\"transport\":\"Ews\"", response.JsonData, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public void UpdateSmtpConfig_WithUnknownTransport_ReturnsManagementError()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://graph.example.test/", 443, "basic", "new@example.com",
                Transport: "Graph"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("Unknown email transport", response.Error);
        Assert.Contains("Graph", response.Error);
    }

    [Fact]
    public void UpdateSmtpConfig_WithEwsTransportAndNonHttpsHost_ReturnsManagementError()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "http://ews.example.test/ews/exchange.asmx", 80, "basic", "new@example.com",
                Transport: "Ews"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("https://", response.Error);
        // The offending value is echoed so the operator sees what was rejected
        // (matches the delivery adapter's message style).
        Assert.Contains("http://ews.example.test/ews/exchange.asmx", response.Error);
    }

    [Fact]
    public void UpdateSmtpConfig_WithEwsTransportAndOAuth2Auth_ReturnsManagementError()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews.example.test/ews/exchange.asmx", 443, "oauth2", "new@example.com",
                Transport: "Ews"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("Basic authentication", response.Error);
    }

    [Fact]
    public void UpdateSmtpConfig_WithEwsTransportAndMalformedCredentials_ReturnsManagementError()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
            Credentials = "example\\notify:fake-password",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        // A supplied credential with no colon at all — the EWS sender cannot build a
        // Basic header from it, so every send would park.
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
                Credentials: "passwordwithoutcolon",
                Transport: "Ews"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("username:password", response.Error);
    }

    [Fact]
    public void UpdateSmtpConfig_WithEwsTransportAndEmptyCredentialHalf_ReturnsManagementError()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        // A trailing colon splits into two parts, but the password half is empty —
        // the adapter rejects it, so the write gate must too.
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
                Credentials: "example\\notify:",
                Transport: "Ews"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("username:password", response.Error);
    }

    [Fact]
    public void UpdateSmtpConfig_FlippingToEwsWithStoredBadCredentials_ReturnsManagementError()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "old.example.com", "Basic", "old@example.com")
        {
            Id = 1,
            Port = 25,
            Credentials = "bare-smtp-password",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        // Credentials omitted entirely: the gate validates the EFFECTIVE state, so the
        // preserved (unusable) stored value is what gets rejected.
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
                Transport: "Ews"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("username:password", response.Error);
    }

    [Fact]
    public void UpdateSmtpConfig_WithNullTransport_PreservesExistingValue()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
        {
            Id = 1,
            Port = 443,
            Transport = "Ews",
            Credentials = "example\\notify:fake-password",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        // Transport omitted (a pre-EWS caller, or Newtonsoft deserializing a
        // payload without the field): the stored transport survives, and the
        // EWS shape rules are still enforced against that preserved value.
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews2.example.test/ews/exchange.asmx", 443, "basic", "new@example.com"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("Ews", existing.Transport);
        Assert.Equal("https://ews2.example.test/ews/exchange.asmx", existing.Host);
    }

    [Theory]
    [InlineData("Smtp")]
    [InlineData("Ews")]
    [InlineData("Graph")]
    [InlineData("")]
    [InlineData(null)]
    public void UpdateSmtpConfig_TransportAcceptance_MatchesEmailTransportParser(string? transport)
    {
        // Parity pin: the write gate re-implements the transport parse inline (ManagementService
        // does not reference NotificationService), so this asserts the mirror accepts exactly the
        // values EmailTransportParser accepts. If the parser gains a transport, this goes red.
        var parserAccepts = true;
        try
        {
            NotificationService.EmailTransportParser.Parse(transport);
        }
        catch (ArgumentException)
        {
            parserAccepts = false;
        }

        var notifRepo = Substitute.For<INotificationRepository>();
        // A fixture valid under EITHER transport, so the transport parse is the only variable.
        var existing = new Commons.Entities.Notifications.SmtpConfiguration(
            "https://ews.example.test/ews/exchange.asmx", "basic", "old@example.com")
        {
            Id = 1,
            Port = 443,
            Credentials = "example\\notify:fake-password",
        };
        notifRepo.GetSmtpConfigurationByIdAsync(1, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmtpConfigCommand(
                1, "https://ews.example.test/ews/exchange.asmx", 443, "basic", "new@example.com",
                Transport: transport),
            "Administrator");

        actor.Tell(envelope);

        if (parserAccepts)
        {
            ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        }
        else
        {
            var error = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
            Assert.Contains("Unknown email transport", error.Error);
        }
    }

    // ========================================================================
    // SMS Notifications (S5) — list Type discriminator + SMS-config management
    // ========================================================================

    [Fact]
    public void CreateNotificationList_WithSmsType_PersistsSms()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        Commons.Entities.Notifications.NotificationList? added = null;
        notifRepo.When(r => r.AddNotificationListAsync(
                Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
            .Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateNotificationListCommand("Ops SMS", new[] { "+15551230000" },
                Commons.Types.Enums.NotificationType.Sms),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(added);
        Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
    }

    [Fact]
    public void CreateNotificationList_WithSmsTypeAndPhones_PersistsForSmsRecipients()
    {
        // S6: an SMS list builds recipients from RecipientPhones via NotificationRecipient.ForSms
        // — PhoneNumber set, EmailAddress null (the off-channel RecipientEmails list is ignored).
        var notifRepo = Substitute.For<INotificationRepository>();
        Commons.Entities.Notifications.NotificationList? added = null;
        notifRepo.When(r => r.AddNotificationListAsync(
                Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
            .Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateNotificationListCommand(
                "Ops SMS",
                Array.Empty<string>(),
                Commons.Types.Enums.NotificationType.Sms,
                new[] { "+15551230000", "+15551230001" }),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(added);
        Assert.Equal(Commons.Types.Enums.NotificationType.Sms, added!.Type);
        Assert.Equal(2, added.Recipients.Count);
        Assert.All(added.Recipients, r =>
        {
            Assert.Null(r.EmailAddress);
            Assert.NotNull(r.PhoneNumber);
        });
        Assert.Equal(new[] { "+15551230000", "+15551230001" },
            added.Recipients.Select(r => r.PhoneNumber).ToArray());
    }

    [Fact]
    public void CreateNotificationList_DefaultType_IsEmail()
    {
        // The Type parameter is a trailing default — existing callers that omit it
        // continue to produce Email lists (additive, non-breaking).
        var notifRepo = Substitute.For<INotificationRepository>();
        Commons.Entities.Notifications.NotificationList? added = null;
        notifRepo.When(r => r.AddNotificationListAsync(
                Arg.Any<Commons.Entities.Notifications.NotificationList>(), Arg.Any<CancellationToken>()))
            .Do(ci => added = ci.Arg<Commons.Entities.Notifications.NotificationList>());
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateNotificationListCommand("Ops Email", new[] { "ops@example.com" }),
            "Designer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.NotNull(added);
        Assert.Equal(Commons.Types.Enums.NotificationType.Email, added!.Type);
    }

    [Fact]
    public void UpdateNotificationList_WithSmsType_PersistsSms()
    {
        // Updating an existing SMS list (same type) must succeed and preserve the
        // SMS channel — the immutability guard only fires when Type changes.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.NotificationList("Ops")
        {
            Id = 7,
            Type = Commons.Types.Enums.NotificationType.Sms,
        };
        notifRepo.GetNotificationListByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
        notifRepo.GetRecipientsByListIdAsync(7, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateNotificationListCommand(7, "Ops", new[] { "+15551230000" },
                Commons.Types.Enums.NotificationType.Sms),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal(Commons.Types.Enums.NotificationType.Sms, existing.Type);
    }

    [Fact]
    public void UpdateNotificationList_ChangingType_IsRejected()
    {
        // Attempting to change an Email list's Type to Sms must return a
        // COMMAND_FAILED error — the Type field is immutable after creation.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.NotificationList("Ops")
        {
            Id = 8,
            Type = Commons.Types.Enums.NotificationType.Email,
        };
        notifRepo.GetNotificationListByIdAsync(8, Arg.Any<CancellationToken>()).Returns(existing);
        notifRepo.GetRecipientsByListIdAsync(8, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateNotificationListCommand(8, "Ops", new[] { "+15551230000" },
                Commons.Types.Enums.NotificationType.Sms),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("Type cannot be changed", response.Error);
    }

    [Fact]
    public void UpdateNotificationList_SameEmailType_Succeeds()
    {
        // Updating an Email list while keeping Type = Email must succeed — the
        // immutability guard must not fire on same-type updates.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.NotificationList("Alerts")
        {
            Id = 9,
            Type = Commons.Types.Enums.NotificationType.Email,
        };
        notifRepo.GetNotificationListByIdAsync(9, Arg.Any<CancellationToken>()).Returns(existing);
        notifRepo.GetRecipientsByListIdAsync(9, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.NotificationRecipient>());
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateNotificationListCommand(9, "Alerts-Renamed", new[] { "ops@example.com" },
                Commons.Types.Enums.NotificationType.Email),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("Alerts-Renamed", existing.Name);
        Assert.Equal(Commons.Types.Enums.NotificationType.Email, existing.Type);
    }

    [Fact]
    public void UpdateSmsConfig_WithApiBaseUrlAndAuthToken_PersistsThem()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
        {
            Id = 1,
            MessagingServiceSid = "MGold",
            ApiBaseUrl = "https://old.example.com",
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", "MGnew",
                "https://new.example.com", "new-secret"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
        Assert.Equal("+15551110000", existing.FromNumber);
        Assert.Equal("MGnew", existing.MessagingServiceSid);
        Assert.Equal("https://new.example.com", existing.ApiBaseUrl);
        Assert.Equal("new-secret", existing.AuthToken);
    }

    [Fact]
    public void UpdateSmsConfig_WithNullAuthTokenAndApiBaseUrl_PreservesExistingValues()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
        {
            Id = 1,
            ApiBaseUrl = "https://old.example.com",
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        // AuthToken + ApiBaseUrl omitted -> preserve-if-null.
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        // Omitted secret/url are preserved, not nulled.
        Assert.Equal("old-secret", existing.AuthToken);
        Assert.Equal("https://old.example.com", existing.ApiBaseUrl);
        // Provided fields are still updated.
        Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
        Assert.Equal("+15551110000", existing.FromNumber);
    }

    [Fact]
    public void UpdateSmsConfig_ResultAndAudit_NeverContainAuthToken()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
        {
            Id = 1,
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        object? auditedAfterState = null;
        _auditService.When(a => a.LogAsync(
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(),
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<object?>(), Arg.Any<CancellationToken>()))
            .Do(ci => auditedAfterState = ci.ArgAt<object?>(5));

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "super-secret-token"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // The secret is applied to the entity ...
        Assert.Equal("super-secret-token", existing.AuthToken);
        // ... but never leaves the boundary in the response payload ...
        Assert.DoesNotContain("super-secret-token", response.JsonData);
        // ... and the public shape signals presence only (camelCase serializer).
        Assert.Contains("hasAuthToken", response.JsonData);
        // ... nor in the audit afterState.
        Assert.NotNull(auditedAfterState);
        Assert.DoesNotContain("super-secret-token",
            ManagementActor.SerializeResult(auditedAfterState));
    }

    [Fact]
    public void ListSmsConfigs_ProjectsAwayAuthToken()
    {
        var notifRepo = Substitute.For<INotificationRepository>();
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration>
            {
                new("ACone", "+15550000000") { Id = 1, AuthToken = "leak-me" },
            });
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        // Read-only -> any authenticated user (no special role required).
        var envelope = Envelope(new ListSmsConfigsCommand());

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.DoesNotContain("leak-me", response.JsonData);
        Assert.Contains("hasAuthToken", response.JsonData);
    }

    [Fact]
    public void UpdateSmsConfig_WithViewerRole_ReturnsUnauthorized()
    {
        // MgmtSvc-021: mutating the SMS provider config rotates the Twilio secret and
        // is Admin-only (the /notifications/sms page is RequireAdmin). A read-only role
        // cannot reach it.
        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000"),
            "Viewer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void UpdateSmsConfig_WithDesignerRole_ReturnsUnauthorized()
    {
        // MgmtSvc-021 regression: a Designer is blocked from the /notifications/sms UI
        // (RequireAdmin), so the actor gate must reject them too — otherwise a Designer
        // could rotate a production Twilio Auth Token via the CLI/Management API.
        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "rotate-me"),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void UpdateSmsConfig_WithEmptyAuthToken_PreservesExistingToken()
    {
        // MgmtSvc-021 regression: an explicit empty/whitespace AuthToken must be treated
        // as "omitted" (preserve the stored secret), never as "clear it" — a Twilio token
        // is always required, so clearing it would 401 every subsequent send.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
        {
            Id = 1,
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", "+15551110000", AuthToken: "   "),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        // The blank token was ignored; the stored secret survives.
        Assert.Equal("old-secret", existing.AuthToken);
        // Non-secret fields still updated.
        Assert.Equal("ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", existing.AccountSid);
    }

    [Fact]
    public void UpdateSmsConfig_MessagingServiceSidOnly_PersistsNullFromNumber()
    {
        // UI-Med-2: a Twilio Messaging-Service-only config has no From number. The handler
        // must accept and persist a null FromNumber alongside a MessagingServiceSid.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
        {
            Id = 1,
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "ACbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", FromNumber: null, MessagingServiceSid: "MGonly"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Null(existing.FromNumber);
        Assert.Equal("MGonly", existing.MessagingServiceSid);
    }

    [Fact]
    public void UpdateSmsConfig_MalformedAccountSid_Rejected()
    {
        // Task 22: AccountSid is interpolated un-escaped into the Twilio request URI,
        // so a malformed SID (e.g. a path-traversal / query-injection payload) must be
        // rejected at save time, closing the URI-injection door before it can be persisted.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "+15550000000")
        {
            Id = 1,
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, "../evil?x=", "+15551110000"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("Account SID", response.Error);
        // The malformed value must never have been persisted.
        Assert.Equal("ACaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", existing.AccountSid);
    }

    [Fact]
    public void UpdateSmsConfig_ValidSid_Accepted()
    {
        // Task 22: a well-formed Twilio SID ('AC' + 32 hex chars) passes the guard.
        var notifRepo = Substitute.For<INotificationRepository>();
        var existing = new Commons.Entities.Notifications.SmsConfiguration("ACold", "+15550000000")
        {
            Id = 1,
            AuthToken = "old-secret",
        };
        notifRepo.GetAllSmsConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmsConfiguration> { existing });
        _services.AddScoped(_ => notifRepo);

        var validSid = "AC" + new string('a', 32);
        var actor = CreateActor();
        var envelope = Envelope(
            new UpdateSmsConfigCommand(1, validSid, "+15551110000"),
            "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal(validSid, existing.AccountSid);
    }

    [Fact]
    public void CuratedHandlerFailure_SurfacesTheCuratedMessage()
    {
        // A handler-thrown ManagementCommandException carries a message that is
        // intentionally safe to surface (e.g. a validation result).
        _templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
            .Returns((Template?)null);

        var actor = CreateActor();
        var envelope = Envelope(new CreateInstanceCommand("BadInst", 99, 1), "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // The curated InstanceService failure message is still surfaced verbatim.
        Assert.Contains("99", response.Error);
    }

    // ========================================================================
    // ManagementService-021: Transport (#24) bundle handler coverage
    //
    // The three bundle handlers (HandleExportBundle / HandlePreviewBundle /
    // HandleImportBundle) at ManagementActor.cs:1717-1897 previously had zero
    // tests. The cases below pin the load-bearing behaviours: role gating,
    // ExportBundle name resolution, ImportBundle blocker rejection, and the
    // ImportBundle (EntityType, Name) dedupe.
    // ========================================================================

    /// <summary>
    /// Adds a substituted <see cref="Commons.Interfaces.Transport.IBundleExporter"/> and
    /// <see cref="Commons.Interfaces.Transport.IBundleImporter"/> to the test
    /// service collection plus minimal repositories the bundle handlers query
    /// from the export side. Returns both substitutes so a test can configure
    /// their behaviour.
    /// </summary>
    private (Commons.Interfaces.Transport.IBundleExporter Exporter,
             Commons.Interfaces.Transport.IBundleImporter Importer)
        AddBundleSubstitutes()
    {
        var exporter = Substitute.For<Commons.Interfaces.Transport.IBundleExporter>();
        var importer = Substitute.For<Commons.Interfaces.Transport.IBundleImporter>();
        _services.AddSingleton(exporter);
        _services.AddSingleton(importer);

        // The repository fan-out HandleExportBundle does at the top of its body.
        // Tests that only exercise role gating still need these resolved.
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Template>());
        _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Scripts.SharedScript>());
        // M8 (B4): export now also fans out to instances (template repo) and sites
        // (site repo) for the site/instance-scoped selection.
        _templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Instance>());

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.Site>());
        _services.AddScoped(_ => siteRepo);

        var externalRepo = Substitute.For<IExternalSystemRepository>();
        externalRepo.GetAllExternalSystemsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.ExternalSystems.ExternalSystemDefinition>());
        externalRepo.GetAllDatabaseConnectionsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.ExternalSystems.DatabaseConnectionDefinition>());
        _services.AddScoped(_ => externalRepo);

        var notifRepo = Substitute.For<INotificationRepository>();
        notifRepo.GetAllNotificationListsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.NotificationList>());
        notifRepo.GetAllSmtpConfigurationsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Notifications.SmtpConfiguration>());
        _services.AddScoped(_ => notifRepo);

        var inboundRepo = Substitute.For<IInboundApiRepository>();
        inboundRepo.GetAllApiMethodsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.InboundApi.ApiMethod>());
        _services.AddScoped(_ => inboundRepo);

        return (exporter, importer);
    }

    private static ExportBundleCommand AllExportCommand() =>
        new(All: true,
            TemplateNames: null, SharedScriptNames: null,
            ExternalSystemNames: null, DatabaseConnectionNames: null,
            NotificationListNames: null, SmtpConfigurationNames: null,
            ApiMethodNames: null,
            IncludeDependencies: false, Passphrase: null,
            SourceEnvironment: "test-env");

    [Fact]
    public void ExportBundleCommand_WithAdminRole_ReturnsUnauthorized()
    {
        // ExportBundle requires the Design role; an Admin-only caller is rejected.
        AddBundleSubstitutes();
        var actor = CreateActor();
        var envelope = Envelope(AllExportCommand(), "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void PreviewBundleCommand_WithDesignRole_ReturnsUnauthorized()
    {
        // PreviewBundle requires the Admin role (Design role isn't enough,
        // mirroring the Central UI gating — only Admin imports cross-cutting
        // configuration).
        AddBundleSubstitutes();
        var actor = CreateActor();
        var envelope = Envelope(new PreviewBundleCommand("AA==", null), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void ImportBundleCommand_WithDesignRole_ReturnsUnauthorized()
    {
        AddBundleSubstitutes();
        var actor = CreateActor();
        var envelope = Envelope(new ImportBundleCommand("AA==", null, "skip"), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Administrator", response.Message);
    }

    [Fact]
    public void ExportBundleCommand_WithUnknownTemplateName_ReturnsManagementError()
    {
        // ResolveIds throws ManagementCommandException for unknown names; that
        // curated message must surface verbatim to the caller.
        AddBundleSubstitutes();
        // No templates in the repo; the export selection names one anyway.
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Template>());

        var cmd = new ExportBundleCommand(
            All: false,
            TemplateNames: new[] { "DoesNotExist" },
            SharedScriptNames: null,
            ExternalSystemNames: null, DatabaseConnectionNames: null,
            NotificationListNames: null, SmtpConfigurationNames: null,
            ApiMethodNames: null,
            IncludeDependencies: false, Passphrase: null,
            SourceEnvironment: "test-env");

        var actor = CreateActor();
        var envelope = Envelope(cmd, "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        // The ManagementCommandException message surfaces verbatim — it names
        // the missing entity type and the missing name.
        Assert.Contains("template", response.Error, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("DoesNotExist", response.Error);
    }

    [Fact]
    public void ExportBundleCommand_WithSiteAndInstanceNames_ResolvesToIds()
    {
        // M8 (B4): --sites accepts SiteIdentifier (preferred) or Name; --instances
        // accepts UniqueName. The handler resolves both to surrogate ids and passes
        // them on the ExportSelection.
        var (exporter, _) = AddBundleSubstitutes();

        var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
            .GetRequiredService<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.Site>
            {
                new("North Plant", "NORTH-01") { Id = 11 },
                new("East Plant", "EAST-02") { Id = 22 },
            });
        _templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Instance>
            {
                new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
                new("NORTH-01.Pump2") { Id = 102, SiteId = 11, TemplateId = 1 },
            });

        Commons.Types.Transport.ExportSelection? captured = null;
        exporter.ExportAsync(
                Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
                Arg.Any<CancellationToken>())
            .Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1, 2, 3 })));

        var cmd = new ExportBundleCommand(
            All: false,
            TemplateNames: null, SharedScriptNames: null,
            ExternalSystemNames: null, DatabaseConnectionNames: null,
            NotificationListNames: null, SmtpConfigurationNames: null,
            ApiMethodNames: null,
            IncludeDependencies: false, Passphrase: null,
            SourceEnvironment: "test-env",
            // mix the SiteIdentifier and the friendly Name to prove the dual-key match.
            SiteNames: new[] { "NORTH-01", "East Plant" },
            InstanceNames: new[] { "NORTH-01.Pump1" });

        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Designer"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.NotNull(captured);
        Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
        Assert.Equal(new[] { 101 }, captured.InstanceIds);
    }

    [Fact]
    public void ExportBundleCommand_WithAll_IncludesEverySiteAndInstance()
    {
        // M8 (B4): All=true ignores per-type name lists and includes every site +
        // instance, mirroring how All already includes every template/etc.
        var (exporter, _) = AddBundleSubstitutes();

        var siteRepo = (ISiteRepository)_services.BuildServiceProvider()
            .GetRequiredService<ISiteRepository>();
        siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.Site>
            {
                new("North Plant", "NORTH-01") { Id = 11 },
                new("East Plant", "EAST-02") { Id = 22 },
            });
        _templateRepo.GetAllInstancesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Instance>
            {
                new("NORTH-01.Pump1") { Id = 101, SiteId = 11, TemplateId = 1 },
                new("EAST-02.Pump9") { Id = 909, SiteId = 22, TemplateId = 1 },
            });

        Commons.Types.Transport.ExportSelection? captured = null;
        exporter.ExportAsync(
                Arg.Do<Commons.Types.Transport.ExportSelection>(s => captured = s),
                Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string?>(),
                Arg.Any<CancellationToken>())
            .Returns(_ => Task.FromResult<Stream>(new MemoryStream(new byte[] { 1 })));

        var actor = CreateActor();
        actor.Tell(Envelope(AllExportCommand(), "Designer"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.NotNull(captured);
        Assert.Equal(new[] { 11, 22 }, captured!.SiteIds.OrderBy(x => x));
        Assert.Equal(new[] { 101, 909 }, captured.InstanceIds.OrderBy(x => x));
    }

    [Fact]
    public void ImportBundleCommand_WithBlockerRow_AbortsBeforeApply()
    {
        // A ConflictKind.Blocker in the preview must abort the import — the
        // handler throws ManagementCommandException before calling ApplyAsync.
        var (_, importer) = AddBundleSubstitutes();

        var sessionId = Guid.NewGuid();
        importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.BundleSession
            {
                SessionId = sessionId,
                Manifest = null!,
                ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
            });

        var blockerItem = new Commons.Types.Transport.ImportPreviewItem(
            EntityType: "Template",
            Name: "BlockedTemplate",
            ExistingVersion: null,
            IncomingVersion: 1,
            Kind: Commons.Types.Transport.ConflictKind.Blocker,
            FieldDiffJson: null,
            BlockerReason: "FK to missing site");
        importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.ImportPreview(
                sessionId,
                new[] { blockerItem }));

        // A non-empty base64 payload that decodes — the handler does its own
        // base64 check before reaching the importer.
        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var actor = CreateActor();
        var envelope = Envelope(new ImportBundleCommand(payload, null, "skip"), "Administrator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("blocker", response.Error, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("BlockedTemplate", response.Error);

        // Apply must NOT have been called — the handler aborts before it.
        importer.DidNotReceive().ApplyAsync(
            Arg.Any<Guid>(),
            Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
            Arg.Any<string>(),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public void ImportBundleCommand_DuplicatePreviewItems_DedupePerEntityTypeAndName()
    {
        // The handler dedupes by (EntityType, Name) before calling ApplyAsync —
        // last-write-wins, matching the Central UI's TransportImport behavior.
        // The preview here emits THREE rows for the same (Template, "Dup"):
        // an Identical then a Modified then an Identical. After dedupe, only
        // one resolution must reach the importer for that key.
        var (_, importer) = AddBundleSubstitutes();

        var sessionId = Guid.NewGuid();
        importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.BundleSession
            {
                SessionId = sessionId,
                Manifest = null!,
                ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
            });

        Commons.Types.Transport.ImportPreviewItem Row(
            Commons.Types.Transport.ConflictKind kind) =>
            new("Template", "Dup", ExistingVersion: 1, IncomingVersion: 2,
                Kind: kind, FieldDiffJson: null, BlockerReason: null);

        importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.ImportPreview(
                sessionId,
                new[]
                {
                    Row(Commons.Types.Transport.ConflictKind.Identical),
                    Row(Commons.Types.Transport.ConflictKind.Modified),
                    Row(Commons.Types.Transport.ConflictKind.Identical),
                }));

        IReadOnlyList<Commons.Types.Transport.ImportResolution>? captured = null;
        // M8 (D3): the handler now calls the 5-arg ApplyAsync overload (trailing
        // nameMap:), so the arrange must include the BundleNameMap arg matcher or
        // NSubstitute treats it as an unmatched call and never runs the capture.
        importer.ApplyAsync(
                Arg.Any<Guid>(),
                Arg.Do<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(
                    r => captured = r),
                Arg.Any<string>(),
                Arg.Any<CancellationToken>(),
                Arg.Any<Commons.Types.Transport.BundleNameMap?>())
            .Returns(new Commons.Types.Transport.ImportResult(
                BundleImportId: Guid.NewGuid(),
                Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
                StaleInstanceIds: Array.Empty<int>(),
                AuditEventCorrelation: "correlation"));

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var actor = CreateActor();
        // "overwrite" policy so the final (Identical) row would otherwise differ
        // from the Modified row's action — proves the last-write-wins semantics.
        var envelope = Envelope(new ImportBundleCommand(payload, null, "overwrite"), "Administrator");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.NotNull(captured);
        // Only ONE resolution survives for the (Template, "Dup") key.
        var dupResolutions = captured!
            .Where(r => r.EntityType == "Template" && r.Name == "Dup")
            .ToList();
        Assert.Single(dupResolutions);
        // Last-write-wins: the final Identical row's Skip action overrides the
        // earlier Modified row's Overwrite action.
        Assert.Equal(Commons.Types.Transport.ResolutionAction.Skip, dupResolutions[0].Action);
    }

    // ------------------------------------------------------------------------
    // M8 (D3): import name-map plumbing — PreviewBundleResult carries the
    // required mappings; HandleImportBundle merges explicit specs + auto-match +
    // create-missing flags into the BundleNameMap passed to ApplyAsync.
    // ------------------------------------------------------------------------

    /// <summary>
    /// Configures the substituted importer with a Load + Preview returning the
    /// supplied required mappings (and no conflicting artifact rows), and a
    /// capturing ApplyAsync. Returns the session id and a getter for the captured
    /// <see cref="Commons.Types.Transport.BundleNameMap"/>.
    /// </summary>
    private static (Guid SessionId, Func<Commons.Types.Transport.BundleNameMap?> CapturedMap) ConfigureImportForMapping(
        Commons.Interfaces.Transport.IBundleImporter importer,
        IReadOnlyList<Commons.Types.Transport.RequiredSiteMapping> siteMappings,
        IReadOnlyList<Commons.Types.Transport.RequiredConnectionMapping> connectionMappings)
    {
        var sessionId = Guid.NewGuid();
        importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.BundleSession
            {
                SessionId = sessionId,
                Manifest = null!,
                ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
            });
        importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.ImportPreview(
                sessionId,
                Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
                siteMappings,
                connectionMappings));

        Commons.Types.Transport.BundleNameMap? captured = null;
        importer.ApplyAsync(
                Arg.Any<Guid>(),
                Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
                Arg.Any<string>(),
                Arg.Any<CancellationToken>(),
                Arg.Do<Commons.Types.Transport.BundleNameMap?>(m => captured = m))
            .Returns(new Commons.Types.Transport.ImportResult(
                BundleImportId: Guid.NewGuid(),
                Added: 0, Overwritten: 0, Skipped: 0, Renamed: 0,
                StaleInstanceIds: Array.Empty<int>(),
                AuditEventCorrelation: "correlation"));

        return (sessionId, () => captured);
    }

    [Fact]
    public void ImportBundleCommand_ExplicitSpec_OverridesAutoMatch()
    {
        // An explicit --map-site spec (target present → MapToExisting) wins over
        // the preview's auto-match suggestion.
        var (_, importer) = AddBundleSubstitutes();
        var (_, capturedMap) = ConfigureImportForMapping(
            importer,
            new[]
            {
                new Commons.Types.Transport.RequiredSiteMapping(
                    "NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
            },
            Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var cmd = new ImportBundleCommand(
            payload, null, "overwrite",
            SiteMappings: new[] { new SiteMappingSpec("NORTH-01", "PLANT-A") });
        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Administrator"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        var map = capturedMap();
        Assert.NotNull(map);
        var site = Assert.Single(map!.Sites);
        Assert.Equal("NORTH-01", site.SourceSiteIdentifier);
        Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
        Assert.Equal("PLANT-A", site.TargetSiteIdentifier);
    }

    [Fact]
    public void ImportBundleCommand_NoSpec_FallsBackToAutoMatch()
    {
        // With no explicit spec, the preview's auto-match becomes a MapToExisting.
        var (_, importer) = AddBundleSubstitutes();
        var (_, capturedMap) = ConfigureImportForMapping(
            importer,
            new[]
            {
                new Commons.Types.Transport.RequiredSiteMapping(
                    "NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
            },
            Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var cmd = new ImportBundleCommand(payload, null, "overwrite");
        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Administrator"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        var map = capturedMap();
        Assert.NotNull(map);
        var site = Assert.Single(map!.Sites);
        Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, site.Action);
        Assert.Equal("AUTO-MATCH", site.TargetSiteIdentifier);
    }

    [Fact]
    public void ImportBundleCommand_NoAutoMatchWithCreateMissing_CreatesNew()
    {
        // No spec, no auto-match, but --create-missing-sites → CreateNew.
        var (_, importer) = AddBundleSubstitutes();
        var (_, capturedMap) = ConfigureImportForMapping(
            importer,
            new[]
            {
                new Commons.Types.Transport.RequiredSiteMapping(
                    "NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
            },
            Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var cmd = new ImportBundleCommand(
            payload, null, "overwrite", CreateMissingSites: true);
        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Administrator"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        var map = capturedMap();
        Assert.NotNull(map);
        var site = Assert.Single(map!.Sites);
        Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
        Assert.Null(site.TargetSiteIdentifier);
    }

    [Fact]
    public void ImportBundleCommand_ExplicitCreateNewSpec_CreatesNew()
    {
        // An explicit spec with a null target means CreateNew even without the
        // create-missing flag.
        var (_, importer) = AddBundleSubstitutes();
        var (_, capturedMap) = ConfigureImportForMapping(
            importer,
            new[]
            {
                new Commons.Types.Transport.RequiredSiteMapping(
                    "NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO-MATCH"),
            },
            Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var cmd = new ImportBundleCommand(
            payload, null, "overwrite",
            SiteMappings: new[] { new SiteMappingSpec("NORTH-01", TargetSiteIdentifier: null) });
        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Administrator"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        var map = capturedMap();
        Assert.NotNull(map);
        var site = Assert.Single(map!.Sites);
        Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, site.Action);
    }

    [Fact]
    public void ImportBundleCommand_UnresolvedWithoutCreateMissing_Fails()
    {
        // No spec, no auto-match, no create-missing flag → unresolved → fail
        // before ApplyAsync.
        var (_, importer) = AddBundleSubstitutes();
        ConfigureImportForMapping(
            importer,
            new[]
            {
                new Commons.Types.Transport.RequiredSiteMapping(
                    "NORTH-01", "North Plant", AutoMatchTargetIdentifier: null),
            },
            Array.Empty<Commons.Types.Transport.RequiredConnectionMapping>());

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var cmd = new ImportBundleCommand(payload, null, "overwrite");
        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Administrator"));

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("unresolved", response.Error, StringComparison.OrdinalIgnoreCase);
        Assert.Contains("NORTH-01", response.Error);

        // Apply must NOT have been called — the merge fails before it.
        importer.DidNotReceive().ApplyAsync(
            Arg.Any<Guid>(),
            Arg.Any<IReadOnlyList<Commons.Types.Transport.ImportResolution>>(),
            Arg.Any<string>(),
            Arg.Any<CancellationToken>(),
            Arg.Any<Commons.Types.Transport.BundleNameMap?>());
    }

    [Fact]
    public void ImportBundleCommand_ConnectionSpecAndCreateMissing_BuildMap()
    {
        // Connection-side mirror: an explicit spec maps to existing, and an
        // unmatched second connection is created via --create-missing-connections.
        var (_, importer) = AddBundleSubstitutes();
        var (_, capturedMap) = ConfigureImportForMapping(
            importer,
            Array.Empty<Commons.Types.Transport.RequiredSiteMapping>(),
            new[]
            {
                new Commons.Types.Transport.RequiredConnectionMapping(
                    "NORTH-01", "OpcA", AutoMatchTargetName: null),
                new Commons.Types.Transport.RequiredConnectionMapping(
                    "NORTH-01", "OpcB", AutoMatchTargetName: null),
            });

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var cmd = new ImportBundleCommand(
            payload, null, "overwrite",
            ConnectionMappings: new[] { new ConnectionMappingSpec("NORTH-01", "OpcA", "OpcLive") },
            CreateMissingConnections: true);
        var actor = CreateActor();
        actor.Tell(Envelope(cmd, "Administrator"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        var map = capturedMap();
        Assert.NotNull(map);
        Assert.Equal(2, map!.Connections.Count);
        var opcA = map.Connections.Single(c => c.SourceConnectionName == "OpcA");
        Assert.Equal(Commons.Types.Transport.MappingAction.MapToExisting, opcA.Action);
        Assert.Equal("OpcLive", opcA.TargetConnectionName);
        var opcB = map.Connections.Single(c => c.SourceConnectionName == "OpcB");
        Assert.Equal(Commons.Types.Transport.MappingAction.CreateNew, opcB.Action);
        Assert.Null(opcB.TargetConnectionName);
    }

    [Fact]
    public void PreviewBundleCommand_SurfacesRequiredMappings()
    {
        // HandlePreviewBundle must echo the preview's required mappings on the
        // PreviewBundleResult so the CLI/UI can show what needs resolving.
        var (_, importer) = AddBundleSubstitutes();

        var sessionId = Guid.NewGuid();
        importer.LoadAsync(Arg.Any<Stream>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.BundleSession
            {
                SessionId = sessionId,
                Manifest = null!,
                ExpiresAt = DateTimeOffset.UtcNow.AddMinutes(5),
            });
        importer.PreviewAsync(sessionId, Arg.Any<CancellationToken>())
            .Returns(new Commons.Types.Transport.ImportPreview(
                sessionId,
                Array.Empty<Commons.Types.Transport.ImportPreviewItem>(),
                new[]
                {
                    new Commons.Types.Transport.RequiredSiteMapping(
                        "NORTH-01", "North Plant", AutoMatchTargetIdentifier: "AUTO"),
                },
                new[]
                {
                    new Commons.Types.Transport.RequiredConnectionMapping(
                        "NORTH-01", "OpcA", AutoMatchTargetName: null),
                }));

        var payload = Convert.ToBase64String(new byte[] { 0x01, 0x02, 0x03 });
        var actor = CreateActor();
        actor.Tell(Envelope(new PreviewBundleCommand(payload, null), "Administrator"));

        var success = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // The result is camelCase JSON (ManagementActor.SerializeResult). The new
        // PreviewBundleResult fields must surface the preview's required mappings.
        Assert.Contains("requiredSiteMappings", success.JsonData);
        Assert.Contains("requiredConnectionMappings", success.JsonData);
        Assert.Contains("NORTH-01", success.JsonData);
        Assert.Contains("North Plant", success.JsonData);
        Assert.Contains("AUTO", success.JsonData);
        Assert.Contains("OpcA", success.JsonData);
    }

    // ========================================================================
    // Native alarm source CRUD (Task 21)
    // ========================================================================

    [Fact]
    public void AddTemplateNativeAlarmSource_WithDesignRole_ReturnsSuccess()
    {
        // Native-source add now propagates inherited rows to derived descendants,
        // so the handler resolves TemplateService (no-op here: the substitute repo's
        // GetAllTemplatesAsync yields no descendants).
        _services.AddScoped<TemplateService>();
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, "desc", false),
            "Designer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _templateRepo.ReceivedWithAnyArgs(1).AddTemplateNativeAlarmSourceAsync(default!, default);
        _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
    }

    [Fact]
    public void AddTemplateNativeAlarmSource_WithDeploymentRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(
            new AddTemplateNativeAlarmSourceCommand(1, "Pressure", "Opc", "ns=2;s=T01", null, null, false),
            "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void ListTemplateNativeAlarmSources_ReturnsData()
    {
        _templateRepo.GetNativeAlarmSourcesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<TemplateNativeAlarmSource>
            {
                new("Pressure") { Id = 1, TemplateId = 1, ConnectionName = "Opc", SourceReference = "ns=2;s=T01" }
            });

        var actor = CreateActor();
        var envelope = Envelope(new ListTemplateNativeAlarmSourcesCommand(1));

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Contains("Pressure", response.JsonData);
    }

    [Fact]
    public void SetInstanceNativeAlarmSourceOverride_WithDeploymentRole_ReturnsSuccess()
    {
        // No prior override → Add path. The source resolves and is UNLOCKED, so the
        // lock guard (#05-T13) lets the override through.
        var pipeline = Substitute.For<IFlatteningPipeline>();
        var flattened = new FlattenedConfiguration
        {
            NativeAlarmSources = new List<ResolvedNativeAlarmSource>
            {
                new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
            }
        };
        pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
        _services.AddScoped(_ => pipeline);

        var actor = CreateActor();
        var envelope = Envelope(
            new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
            "Deployer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _templateRepo.ReceivedWithAnyArgs(1).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
        _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
    }

    [Fact]
    public void SetInstanceNativeAlarmSourceOverride_WithDesignRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(
            new SetInstanceNativeAlarmSourceOverrideCommand(1, "Pressure", "Opc2", "ns=2;s=NEW", null),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Deployer", response.Message);
    }

    [Fact]
    public void SetInstanceNativeAlarmSourceOverrides_BulkHappyPath_AddsAllAndSavesOnce()
    {
        // #12: bulk CSV apply. Both sources resolve and are unlocked → each is added,
        // and the whole batch commits with a SINGLE SaveChanges (all-or-nothing txn).
        var pipeline = Substitute.For<IFlatteningPipeline>();
        var flattened = new FlattenedConfiguration
        {
            NativeAlarmSources = new List<ResolvedNativeAlarmSource>
            {
                new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" },
                new() { CanonicalName = "Temp", IsLocked = false, Source = "Template" }
            }
        };
        pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
        _services.AddScoped(_ => pipeline);

        var actor = CreateActor();
        var envelope = Envelope(
            new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
            {
                new("Pressure", "Opc2", "ns=2;s=P", null),
                new("Temp", null, "ns=2;s=T", "Active")
            }),
            "Deployer");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _templateRepo.ReceivedWithAnyArgs(2).AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
        // All-or-nothing: exactly one commit for the whole batch.
        _templateRepo.ReceivedWithAnyArgs(1).SaveChangesAsync(default);
    }

    [Fact]
    public void SetInstanceNativeAlarmSourceOverrides_OneLockedSource_RejectsWholeBatch()
    {
        // #12: all-or-nothing. If ANY entry targets a locked source, the whole batch
        // is rejected before any write — no Add, no SaveChanges.
        var pipeline = Substitute.For<IFlatteningPipeline>();
        var flattened = new FlattenedConfiguration
        {
            NativeAlarmSources = new List<ResolvedNativeAlarmSource>
            {
                new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" },
                new() { CanonicalName = "Locked", IsLocked = true, Source = "Template" }
            }
        };
        pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
        _services.AddScoped(_ => pipeline);

        var actor = CreateActor();
        var envelope = Envelope(
            new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
            {
                new("Pressure", "Opc2", null, null),
                new("Locked", "Opc2", null, null)
            }),
            "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("locked", response.Error, StringComparison.OrdinalIgnoreCase);
        _templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
        _templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default);
    }

    [Fact]
    public void SetInstanceNativeAlarmSourceOverrides_UnresolvedSource_RejectsWholeBatch()
    {
        // #12: a source that does not resolve for the instance is rejected up front.
        var pipeline = Substitute.For<IFlatteningPipeline>();
        var flattened = new FlattenedConfiguration
        {
            NativeAlarmSources = new List<ResolvedNativeAlarmSource>
            {
                new() { CanonicalName = "Pressure", IsLocked = false, Source = "Template" }
            }
        };
        pipeline.FlattenAndValidateAsync(1, Arg.Any<CancellationToken>(), Arg.Any<bool>())
            .Returns(Result<FlatteningPipelineResult>.Success(
                new FlatteningPipelineResult(flattened, "hash", ValidationResult.Success())));
        _services.AddScoped(_ => pipeline);

        var actor = CreateActor();
        var envelope = Envelope(
            new SetInstanceNativeAlarmSourceOverridesCommand(1, new List<NativeAlarmSourceOverrideEntry>
            {
                new("Pressure", "Opc2", null, null),
                new("Ghost", "Opc2", null, null)
            }),
            "Deployer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("does not resolve", response.Error, StringComparison.OrdinalIgnoreCase);
        _templateRepo.DidNotReceiveWithAnyArgs().AddInstanceNativeAlarmSourceOverrideAsync(default!, default);
        _templateRepo.DidNotReceiveWithAnyArgs().SaveChangesAsync(default);
    }

    // ========================================================================
    // MoveDataConnectionCommand (M9 / T24a) — guarded cross-site move
    // ========================================================================

    [Fact]
    public void MoveDataConnection_WithViewerRole_ReturnsUnauthorized()
    {
        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Viewer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Designer", response.Message);
    }

    [Fact]
    public void MoveDataConnection_HappyPath_MovesSiteIdAndAudits()
    {
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
        var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
        // No name collision at the target site.
        siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.DataConnection>());
        // No bindings reference the connection -> move allowed.
        siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
            .Returns(new List<Instance>());
        // No native-alarm-source overrides on the source site (purpose-built load).
        siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Instance>());
        siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        // SiteId is mutated to the target.
        Assert.Equal(2, conn.SiteId);
        siteRepo.Received(1).UpdateDataConnectionAsync(
            Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
            Arg.Any<CancellationToken>());
        _auditService.Received(1).LogAsync(
            "testuser", "Move", "DataConnection", "5", "Opc1",
            Arg.Any<object?>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_TargetSiteMissing_ReturnsError()
    {
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(99, Arg.Any<CancellationToken>())
            .Returns((Commons.Entities.Sites.Site?)null);
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 99), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("99", response.Error);
        // SiteId unchanged; no write occurred.
        Assert.Equal(1, conn.SiteId);
        siteRepo.DidNotReceive().UpdateDataConnectionAsync(
            Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_NameCollisionAtTarget_ReturnsError()
    {
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
        var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
        var collidingConn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 2) { Id = 9 };

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
        siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.DataConnection> { collidingConn });
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("Opc1", response.Error);
        Assert.Equal(1, conn.SiteId);
        siteRepo.DidNotReceive().UpdateDataConnectionAsync(
            Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_BlockedByBinding_ReturnsErrorNamingInstances()
    {
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
        var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };
        var blockingInstance = new Instance("Tank01") { Id = 7, SiteId = 1 };

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
        siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.DataConnection>());
        // A binding references the connection -> move must be rejected.
        siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
            .Returns(new List<Instance> { blockingInstance });
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        // Error names the blocking instance.
        Assert.Contains("Tank01", response.Error);
        // SiteId unchanged; no write occurred.
        Assert.Equal(1, conn.SiteId);
        siteRepo.DidNotReceive().UpdateDataConnectionAsync(
            Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_SameSite_ReturnsErrorNoWrite()
    {
        // Moving a connection to the site it already belongs to is a no-op and
        // must be rejected before any write — and before the existence/collision
        // guards even run.
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        _services.AddScoped(_ => siteRepo);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 1), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("already belongs", response.Error);
        Assert.Equal(1, conn.SiteId);
        siteRepo.DidNotReceive().UpdateDataConnectionAsync(
            Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
        siteRepo.DidNotReceive().SaveChangesAsync(Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_BlockedByTemplateNativeAlarmSourceOnSourceSite_ReturnsError()
    {
        // A template instantiated on the SOURCE site has a native-alarm-source
        // whose ConnectionName matches the moving connection -> blocked, because
        // the deploying-site flatten would no longer resolve that name.
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
        var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };

        // Source-site instance referencing template 42.
        var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };

        var template = new Template("PumpTemplate") { Id = 42 };
        template.NativeAlarmSources.Add(
            new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
        siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.DataConnection>());
        siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
            .Returns(new List<Instance>());
        // Source-site instances (purpose-built load with overrides eagerly loaded).
        siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Instance> { sourceInstance });
        _services.AddScoped(_ => siteRepo);

        // Only template 42 (instantiated on the source site) is scanned.
        _templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(template);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        // Error names the template and its native-alarm-source.
        Assert.Contains("PumpTemplate", response.Error);
        Assert.Contains("PumpFaults", response.Error);
        Assert.Equal(1, conn.SiteId);
        siteRepo.DidNotReceive().UpdateDataConnectionAsync(
            Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_BlockedByInstanceOverrideOnSourceSite_ReturnsError()
    {
        // A source-site instance has a native-alarm-source override whose
        // ConnectionNameOverride matches the moving connection -> blocked.
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
        var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };

        var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 42 };
        sourceInstance.NativeAlarmSourceOverrides.Add(
            new InstanceNativeAlarmSourceOverride("PumpFaults")
            { Id = 1, InstanceId = 7, ConnectionNameOverride = "Opc1" });

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
        siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.DataConnection>());
        siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
            .Returns(new List<Instance>());
        siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Instance> { sourceInstance });
        _services.AddScoped(_ => siteRepo);

        // Template 42 has no matching native-alarm-source; the override is the blocker.
        _templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>())
            .Returns(new Template("PumpTemplate") { Id = 42 });

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        // Error names the instance and its override.
        Assert.Contains("Tank01", response.Error);
        Assert.Contains("PumpFaults", response.Error);
        Assert.Equal(1, conn.SiteId);
        siteRepo.DidNotReceive().UpdateDataConnectionAsync(
            Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void MoveDataConnection_TemplateReferencedOnlyByOtherSite_DoesNotBlock()
    {
        // Regression for the over-blocking false positive: a template references
        // the connection NAME ("Opc1") but is instantiated ONLY on another site.
        // Templates are site-agnostic and ConnectionName resolves against the
        // DEPLOYING site's pool, so moving site 1's "Opc1" does not break the
        // other site's deployment. The move must succeed.
        var conn = new Commons.Entities.Sites.DataConnection("Opc1", "OpcUa", 1) { Id = 5 };
        var targetSite = new Commons.Entities.Sites.Site("Target", "SITE-B") { Id = 2 };

        // The SOURCE site (1) has an instance using template 99, which does NOT
        // reference "Opc1". A different template (42) referencing "Opc1" exists
        // but is instantiated only on the target/other site, so it is never loaded.
        var sourceInstance = new Instance("Tank01") { Id = 7, SiteId = 1, TemplateId = 99 };

        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetDataConnectionByIdAsync(5, Arg.Any<CancellationToken>()).Returns(conn);
        siteRepo.GetSiteByIdAsync(2, Arg.Any<CancellationToken>()).Returns(targetSite);
        siteRepo.GetDataConnectionsBySiteIdAsync(2, Arg.Any<CancellationToken>())
            .Returns(new List<Commons.Entities.Sites.DataConnection>());
        siteRepo.GetInstancesReferencingDataConnectionAsync(5, Arg.Any<CancellationToken>())
            .Returns(new List<Instance>());
        siteRepo.GetInstancesWithNativeAlarmOverridesBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Instance> { sourceInstance });
        siteRepo.UpdateDataConnectionAsync(Arg.Any<Commons.Entities.Sites.DataConnection>(), Arg.Any<CancellationToken>())
            .Returns(Task.CompletedTask);
        siteRepo.SaveChangesAsync(Arg.Any<CancellationToken>()).Returns(1);
        _services.AddScoped(_ => siteRepo);

        // Template 99 (the one actually on the source site) does not reference "Opc1".
        _templateRepo.GetTemplateByIdAsync(99, Arg.Any<CancellationToken>())
            .Returns(new Template("OtherTemplate") { Id = 99 });
        // Template 42 references "Opc1" but lives only on another site: if the
        // handler scoped its scan correctly it must NEVER load template 42. We
        // still stub it to prove the cross-site reference is irrelevant.
        var crossSiteTemplate = new Template("PumpTemplate") { Id = 42 };
        crossSiteTemplate.NativeAlarmSources.Add(
            new TemplateNativeAlarmSource("PumpFaults") { Id = 1, TemplateId = 42, ConnectionName = "Opc1" });
        _templateRepo.GetTemplateByIdAsync(42, Arg.Any<CancellationToken>()).Returns(crossSiteTemplate);

        var actor = CreateActor();
        var envelope = Envelope(new MoveDataConnectionCommand(5, 2), "Designer");

        actor.Tell(envelope);

        // Move SUCCEEDS: the cross-site template reference must not block.
        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal(2, conn.SiteId);
        siteRepo.Received(1).UpdateDataConnectionAsync(
            Arg.Is<Commons.Entities.Sites.DataConnection>(c => c.Id == 5 && c.SiteId == 2),
            Arg.Any<CancellationToken>());
        // The off-site template (42) must never be scanned.
        _templateRepo.DidNotReceive().GetTemplateByIdAsync(42, Arg.Any<CancellationToken>());
    }

    // ========================================================================
    // #259: design-time ValidateTemplateCommand passes sharedScripts so a
    // dangling $ref in a SHARED script's schema is caught at design time, not
    // only on the deploy path.
    // ========================================================================

    [Fact]
    public void ValidateTemplate_DanglingRefInSharedScript_ReturnsInvalidResult()
    {
        // Arrange
        var template = new Template("T1") { Id = 1 };

        _templateRepo.GetTemplateWithChildrenAsync(1, Arg.Any<CancellationToken>())
            .Returns(template);
        _templateRepo.GetAttributesByTemplateIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<TemplateAttribute>());
        _templateRepo.GetAlarmsByTemplateIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<TemplateAlarm>());
        _templateRepo.GetScriptsByTemplateIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<TemplateScript>());

        // Shared script whose parameter schema contains a dangling $ref.
        _templateRepo.GetAllSharedScriptsAsync(Arg.Any<CancellationToken>())
            .Returns(new List<SharedScript>
            {
                new SharedScript("ComputeOrder", "var x = 1;")
                {
                    ParameterDefinitions = """{"$ref":"lib:MissingSharedSchema"}"""
                }
            });

        // Schema library is empty → the $ref cannot be resolved and dangles.
        var schemaRepo = Substitute.For<ISharedSchemaRepository>();
        schemaRepo.ListAsync(Arg.Any<CancellationToken>())
            .Returns(new List<SharedSchema>());
        _services.AddScoped(_ => schemaRepo);

        // TemplateService is required by the collision-detection step inside HandleValidateTemplate.
        _templateRepo.GetTemplateByIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(template);
        _templateRepo.GetAllTemplatesAsync(Arg.Any<CancellationToken>())
            .Returns(new List<Template> { template });
        _services.AddScoped<TemplateService>();

        var actor = CreateActor();
        var envelope = Envelope(new ValidateTemplateCommand(1), "Designer");

        // Act
        actor.Tell(envelope);

        // Assert: the handler returns ManagementSuccess whose JSON payload signals an invalid
        // ValidationResult with a SchemaReference error naming the missing library entry.
        var msg = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(10));
        Assert.Equal(envelope.CorrelationId, msg.CorrelationId);
        // The ValidationResult is JSON-serialised into JsonData; check key fields in the raw JSON
        // (consistent with how other ManagementActorTests verify structured payloads).
        // ValidationCategory is serialised as an integer; SchemaReference is ordinal 15.
        Assert.Contains("\"isValid\":false", msg.JsonData, StringComparison.Ordinal);
        Assert.Contains("MissingSharedSchema", msg.JsonData, StringComparison.Ordinal);
        Assert.Contains("\"category\":15", msg.JsonData, StringComparison.Ordinal);
    }

    // ========================================================================
    // PLAN-06 Task 11: per-system ESG TimeoutSeconds management commands
    // ========================================================================

    [Fact]
    public void CreateExternalSystem_PersistsTimeoutSeconds()
    {
        var extRepo = Substitute.For<IExternalSystemRepository>();
        _services.AddScoped(_ => extRepo);

        var actor = CreateActor();
        var envelope = Envelope(
            new CreateExternalSystemCommand("Sys", "http://x", "none", null, TimeoutSeconds: 15),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        extRepo.Received(1).AddExternalSystemAsync(
            Arg.Is<Commons.Entities.ExternalSystems.ExternalSystemDefinition>(d => d.TimeoutSeconds == 15),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public void UpdateExternalSystem_NullTimeout_PreservesExisting()
    {
        var existing = new Commons.Entities.ExternalSystems.ExternalSystemDefinition("Sys", "http://x", "none")
        {
            Id = 7,
            TimeoutSeconds = 42
        };
        var extRepo = Substitute.For<IExternalSystemRepository>();
        extRepo.GetExternalSystemByIdAsync(7, Arg.Any<CancellationToken>()).Returns(existing);
        _services.AddScoped(_ => extRepo);

        var actor = CreateActor();
        // TimeoutSeconds omitted (null) => existing 42 must be preserved.
        var envelope = Envelope(
            new UpdateExternalSystemCommand(7, "Sys", "http://x", "none", null),
            "Designer");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal(42, existing.TimeoutSeconds);
        extRepo.Received(1).UpdateExternalSystemAsync(
            Arg.Is<Commons.Entities.ExternalSystems.ExternalSystemDefinition>(d => d.TimeoutSeconds == 42),
            Arg.Any<CancellationToken>());
    }

    // ========================================================================
    // Area management role gate (arch-review C6): reconciled to any-of
    // [Designer, Deployer]. The Central UI creates areas inside the Deployment
    // Topology workflow (RequireDeployment), so a pure-Deployer must be allowed.
    // ========================================================================

    [Theory]
    [InlineData("Designer")]
    [InlineData("Deployer")]
    public void CreateArea_DesignerOrDeployer_Allowed(string role)
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), role));
        // The gate must let both roles through. Dispatch may still fail (no
        // AreaService wired), but it must NOT be an authorization rejection.
        var resp = ExpectMsg<object>(TimeSpan.FromSeconds(5));
        Assert.IsNotType<ManagementUnauthorized>(resp);
    }

    [Fact]
    public void CreateArea_ViewerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new CreateAreaCommand(1, "Area1", null), "Viewer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    // ========================================================================
    // MoveArea (CLI/management parity follow-up from the deployment-topology
    // plan): re-parenting is the same structural authoring act as create /
    // update / delete, so it carries the identical any-of [Designer, Deployer]
    // gate, and every validation rule is delegated to AreaService.MoveAreaAsync.
    // ========================================================================

    [Theory]
    [InlineData("Designer")]
    [InlineData("Deployer")]
    public void MoveArea_DesignerOrDeployer_Allowed(string role)
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new MoveAreaCommand(1, 2), role));
        var resp = ExpectMsg<object>(TimeSpan.FromSeconds(5));
        Assert.IsNotType<ManagementUnauthorized>(resp);
    }

    [Fact]
    public void MoveArea_ViewerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Viewer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void MoveArea_ToNewParent_ReturnsSuccessAndReparentsArea()
    {
        _services.AddScoped<AreaService>();
        var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 };
        var target = new Area("Building2") { Id = 2, SiteId = 1 };
        _templateRepo.GetAreaByIdAsync(3, Arg.Any<CancellationToken>()).Returns(moving);
        _templateRepo.GetAreaByIdAsync(2, Arg.Any<CancellationToken>()).Returns(target);
        _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Area> { new("Building1") { Id = 1, SiteId = 1 }, target, moving });

        var actor = CreateActor();
        var envelope = Envelope(new MoveAreaCommand(3, 2), "Deployer");
        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Equal(2, moving.ParentAreaId);
    }

    [Fact]
    public void MoveArea_WithNullParent_MovesToSiteRoot()
    {
        _services.AddScoped<AreaService>();
        var moving = new Area("Line1") { Id = 3, SiteId = 1, ParentAreaId = 1 };
        _templateRepo.GetAreaByIdAsync(3, Arg.Any<CancellationToken>()).Returns(moving);
        _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Area> { new("Building1") { Id = 1, SiteId = 1 }, moving });

        var actor = CreateActor();
        actor.Tell(Envelope(new MoveAreaCommand(3, null), "Designer"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Null(moving.ParentAreaId);
    }

    [Fact]
    public void MoveArea_UnderOwnDescendant_ReturnsError()
    {
        _services.AddScoped<AreaService>();
        var parent = new Area("Building1") { Id = 1, SiteId = 1 };
        var child = new Area("Line1") { Id = 2, SiteId = 1, ParentAreaId = 1 };
        _templateRepo.GetAreaByIdAsync(1, Arg.Any<CancellationToken>()).Returns(parent);
        _templateRepo.GetAreaByIdAsync(2, Arg.Any<CancellationToken>()).Returns(child);
        _templateRepo.GetAreasBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<Area> { parent, child });

        var actor = CreateActor();
        actor.Tell(Envelope(new MoveAreaCommand(1, 2), "Designer"));

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("descendants", response.Error, StringComparison.OrdinalIgnoreCase);
    }

    [Fact]
    public void MoveArea_AreaNotFound_ReturnsError()
    {
        _services.AddScoped<AreaService>();
        _templateRepo.GetAreaByIdAsync(99, Arg.Any<CancellationToken>()).Returns((Area?)null);

        var actor = CreateActor();
        actor.Tell(Envelope(new MoveAreaCommand(99, null), "Designer"));

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("99", response.Error);
    }

    // ========================================================================
    // Remote-query actor dispatch for the OPC UA design-time commands
    // (arch-review C4): BrowseNode / SearchAddressSpace / VerifyEndpoint gain
    // actor handlers so the management API/CLI reaches parity with the UI.
    // SiteIdentifier is REQUIRED (the actor must route to a site); Designer-gated.
    // ========================================================================

    [Fact]
    public void BrowseNode_WithoutSiteIdentifier_ReturnsError()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null), "Designer"));
        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("SiteIdentifier", resp.Error);
    }

    [Fact]
    public void BrowseNode_Designer_RelaysToSite()
    {
        var stub = new StubCommunicationService();
        _services.AddSingleton<CommunicationService>(stub);
        var actor = CreateActor();
        actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Designer"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, stub.BrowseCount);
        Assert.Equal("SITE1", stub.LastBrowseSite);
    }

    [Fact]
    public void BrowseNode_ViewerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new BrowseNodeCommand("conn", null, null, "SITE1"), "Viewer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void SearchAddressSpace_WithoutSiteIdentifier_ReturnsError()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50), "Designer"));
        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("SiteIdentifier", resp.Error);
    }

    [Fact]
    public void SearchAddressSpace_Designer_RelaysToSite()
    {
        var stub = new StubCommunicationService();
        _services.AddSingleton<CommunicationService>(stub);
        var actor = CreateActor();
        actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Designer"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, stub.SearchCount);
        Assert.Equal("SITE1", stub.LastSearchSite);
    }

    [Fact]
    public void SearchAddressSpace_ViewerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new SearchAddressSpaceCommand("conn", "q", 3, 50, "SITE1"), "Viewer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void VerifyEndpoint_WithoutSiteIdentifier_ReturnsError()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}"), "Designer"));
        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("SiteIdentifier", resp.Error);
    }

    [Fact]
    public void VerifyEndpoint_Designer_RelaysToSite()
    {
        var stub = new StubCommunicationService();
        _services.AddSingleton<CommunicationService>(stub);
        var actor = CreateActor();
        actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Designer"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, stub.VerifyCount);
        Assert.Equal("SITE1", stub.LastVerifySite);
    }

    [Fact]
    public void VerifyEndpoint_ViewerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new VerifyEndpointCommand("conn", "OpcUa", "{}", "SITE1"), "Viewer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    // ========================================================================
    // Admin-gated actor dispatch for the site cert-trust commands
    // (arch-review C4): TrustServerCert / RemoveServerCert / ListServerCerts.
    // These mutate the site's trusted-peer PKI store, so they are gated
    // Administrator — matching the Admin-gated cert-management UI. SiteIdentifier
    // is REQUIRED; the handler only relays (site-side reconcile is CertStoreActor).
    // ========================================================================

    [Fact]
    public void TrustServerCert_DesignerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Designer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void TrustServerCert_WithoutSiteIdentifier_ReturnsError()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12"), "Administrator"));
        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("SiteIdentifier", resp.Error);
    }

    [Fact]
    public void TrustServerCert_Admin_RelaysToSite()
    {
        var stub = new StubCommunicationService();
        _services.AddSingleton<CommunicationService>(stub);
        var actor = CreateActor();
        actor.Tell(Envelope(new TrustServerCertCommand("conn", "ZGVy", "AB12", "SITE1"), "Administrator"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, stub.TrustCount);
        Assert.Equal("SITE1", stub.LastTrustSite);
    }

    [Fact]
    public void RemoveServerCert_Admin_RelaysToSite()
    {
        var stub = new StubCommunicationService();
        _services.AddSingleton<CommunicationService>(stub);
        var actor = CreateActor();
        actor.Tell(Envelope(new RemoveServerCertCommand("AB12", "SITE1"), "Administrator"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, stub.RemoveCount);
        Assert.Equal("SITE1", stub.LastRemoveSite);
    }

    [Fact]
    public void RemoveServerCert_WithoutSiteIdentifier_ReturnsError()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new RemoveServerCertCommand("AB12"), "Administrator"));
        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("SiteIdentifier", resp.Error);
    }

    [Fact]
    public void ListServerCerts_Admin_RelaysToSite()
    {
        var stub = new StubCommunicationService();
        _services.AddSingleton<CommunicationService>(stub);
        var actor = CreateActor();
        actor.Tell(Envelope(new ListServerCertsCommand("SITE1"), "Administrator"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, stub.ListCertsCount);
        Assert.Equal("SITE1", stub.LastListCertsSite);
    }

    [Fact]
    public void ListServerCerts_DesignerOnly_Unauthorized()
    {
        var actor = CreateActor();
        actor.Tell(Envelope(new ListServerCertsCommand("SITE1"), "Designer"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    // ── R2 T13: site delete disposes the site's cached gRPC channels (N8) ──

    [Fact]
    public void DeleteSite_DisposesTheSitesCachedGrpcChannels()
    {
        var siteRepo = Substitute.For<ISiteRepository>();
        siteRepo.GetSiteByIdAsync(7, Arg.Any<CancellationToken>())
            .Returns(new Site("Seven", "site-7") { Id = 7 });
        siteRepo.GetInstancesBySiteIdAsync(7, Arg.Any<CancellationToken>())
            .Returns((IReadOnlyList<Instance>)new List<Instance>());
        _services.AddScoped(_ => siteRepo);

        var factory = new TrackingGrpcFactory();
        var cached = (TrackingClient)factory.GetOrCreate("site-7", "http://node-a:8083");
        _services.AddSingleton<SiteStreamGrpcClientFactory>(factory);

        var actor = CreateActor();
        actor.Tell(Envelope(new DeleteSiteCommand(7), "Administrator"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));

        AwaitAssert(() =>
        {
            Assert.True(cached.Disposed);
            Assert.Null(factory.TryGet("site-7", "http://node-a:8083"));
        }, TimeSpan.FromSeconds(5));
    }

    private sealed class TrackingClient : SiteStreamGrpcClient
    {
        public TrackingClient(string endpoint) : base(endpoint) { }
        public bool Disposed { get; private set; }
        public override void Dispose() => Disposed = true;
        public override ValueTask DisposeAsync()
        {
            Disposed = true;
            return ValueTask.CompletedTask;
        }
    }

    private sealed class TrackingGrpcFactory : SiteStreamGrpcClientFactory
    {
        public TrackingGrpcFactory() : base(NullLoggerFactory.Instance) { }
        protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
            => new TrackingClient(grpcEndpoint);
    }

    /// <summary>
    /// Records remote-query relays for the actor-dispatch tests. The browse /
    /// search / verify / cert-trust methods on <see cref="CommunicationService"/>
    /// are virtual so the relay can be exercised without a live actor system.
    /// </summary>
    private sealed class StubCommunicationService : CommunicationService
    {
        public StubCommunicationService()
            : base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
        {
        }

        public int BrowseCount { get; private set; }
        public string? LastBrowseSite { get; private set; }
        public int SearchCount { get; private set; }
        public string? LastSearchSite { get; private set; }
        public int VerifyCount { get; private set; }
        public string? LastVerifySite { get; private set; }
        public int TrustCount { get; private set; }
        public string? LastTrustSite { get; private set; }
        public int RemoveCount { get; private set; }
        public string? LastRemoveSite { get; private set; }
        public int ListCertsCount { get; private set; }
        public string? LastListCertsSite { get; private set; }

        public override Task<CertTrustResult> TrustServerCertAsync(
            string siteId, TrustServerCertCommand command, CancellationToken ct = default)
        {
            TrustCount++;
            LastTrustSite = siteId;
            return Task.FromResult(new CertTrustResult(true, null, null));
        }

        public override Task<CertTrustResult> RemoveServerCertAsync(
            string siteId, RemoveServerCertCommand command, CancellationToken ct = default)
        {
            RemoveCount++;
            LastRemoveSite = siteId;
            return Task.FromResult(new CertTrustResult(true, null, null));
        }

        public override Task<CertTrustResult> ListServerCertsAsync(
            string siteId, ListServerCertsCommand command, CancellationToken ct = default)
        {
            ListCertsCount++;
            LastListCertsSite = siteId;
            return Task.FromResult(new CertTrustResult(true, null, null));
        }

        public override Task<BrowseNodeResult> BrowseNodeAsync(
            string siteId, BrowseNodeCommand command, CancellationToken ct = default)
        {
            BrowseCount++;
            LastBrowseSite = siteId;
            return Task.FromResult(new BrowseNodeResult(Array.Empty<BrowseNode>(), false, null));
        }

        public override Task<SearchAddressSpaceResult> SearchAddressSpaceAsync(
            string siteId, SearchAddressSpaceCommand command, CancellationToken ct = default)
        {
            SearchCount++;
            LastSearchSite = siteId;
            return Task.FromResult(new SearchAddressSpaceResult(Array.Empty<AddressSpaceMatch>(), false, null));
        }

        public override Task<VerifyEndpointResult> VerifyEndpointAsync(
            string siteId, VerifyEndpointCommand command, CancellationToken ct = default)
        {
            VerifyCount++;
            LastVerifySite = siteId;
            return Task.FromResult(new VerifyEndpointResult(true, null, null, null));
        }
    }
}
