using Akka.Actor;
using Akka.TestKit.Xunit2;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using ScadaLink.Commons.Entities.Instances;
using ScadaLink.Commons.Entities.Templates;
using ScadaLink.Commons.Interfaces.Repositories;
using ScadaLink.Commons.Interfaces.Services;
using ScadaLink.Commons.Messages.Management;
using ScadaLink.Commons.Types;
using ScadaLink.ManagementService;
using ScadaLink.TemplateEngine;
using ScadaLink.TemplateEngine.Services;

namespace ScadaLink.ManagementService.Tests;

public class ManagementActorTests : TestKit, IDisposable
{
    private readonly ITemplateEngineRepository _templateRepo;
    private readonly IAuditService _auditService;
    private readonly ServiceCollection _services;

    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"));

    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"), "Design");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Admin", 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("Admin", response.Message);
    }

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

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Deployment", 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(), "Design");

        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()
    {
        var templates = new List<Template>
        {
            new("PumpTemplate") { Id = 1, Description = "Pump" },
            new("ValveTemplate") { Id = 2, Description = "Valve" }
        };
        _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.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),
            "Deployment");

        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);
    }

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

    [Fact]
    public void ListTemplatesCommand_WhenRepoThrows_ReturnsManagementError()
    {
        _templateRepo.GetAllTemplatesAsync(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);
        Assert.Contains("Database connection lost", 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),
            "Deployment");

        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 "Design" role, "Admin" alone is insufficient
        var actor = CreateActor();
        var envelope = Envelope(
            new CreateTemplateCommand("T1", null, null),
            "Admin");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Design", 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"),
            "Admin");

        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();
        // "admin" lowercase should still match "Admin" requirement
        var envelope = Envelope(
            new CreateSiteCommand("Site2", "S2", null),
            "admin");

        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), "Admin");

        actor.Tell(envelope);

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

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

        actor.Tell(envelope);

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

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

        actor.Tell(envelope);

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

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

        actor.Tell(envelope);

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

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

        actor.Tell(envelope);

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

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

        actor.Tell(envelope);

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

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

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        Assert.Contains("Design", 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), "Admin");

        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);
        Assert.Contains("Connection refused", 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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Admin");

        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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Deployment");

        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" }, "Deployment");

        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.
    // ========================================================================

    [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);
    }
}
