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.AuditLog.Central;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.SecuredWrites;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication;
using AuditEvent = ZB.MOM.WW.Audit.AuditEvent;

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

/// <summary>
/// Handler tests for the two-person ("secured") write submit/reject/list flow
/// (M7 OPC UA / MxGateway UX, Task T14b). Submit is gated to Operator, reject to
/// Verifier; the no-self-approval rule and the MxGateway-protocol precondition
/// are enforced inside the handlers and surface as ManagementError.
/// </summary>
public class SecuredWriteHandlerTests : TestKit, IDisposable
{
    /// <summary>
    /// The central node the test writer stamps onto every emitted row. #206 routes
    /// SecuredWrite audit through <see cref="ICentralAuditWriter"/>, whose
    /// <see cref="INodeIdentityProvider"/> stamps <c>SourceNode</c>; the handler tests
    /// assert each lifecycle row carries this value (proving the stamping actually runs).
    /// </summary>
    private const string ExpectedSourceNode = "central-a";

    private readonly ISiteRepository _siteRepo;
    private readonly ISecuredWriteRepository _securedWriteRepo;
    private readonly IAuditLogRepository _auditRepo;
    private readonly StubCommunicationService _comms;
    private readonly ServiceCollection _services;

    public SecuredWriteHandlerTests()
    {
        _siteRepo = Substitute.For<ISiteRepository>();
        _securedWriteRepo = Substitute.For<ISecuredWriteRepository>();
        _auditRepo = Substitute.For<IAuditLogRepository>();
        _comms = new StubCommunicationService();

        _services = new ServiceCollection();
        _services.AddScoped(_ => _siteRepo);
        _services.AddScoped(_ => _securedWriteRepo);
        _services.AddScoped(_ => _auditRepo);
        _services.AddSingleton<CommunicationService>(_comms);

        // Server-side secured-write TTL (arch-review S2): the expiry helper reads
        // SecuredWritePendingTtl off IOptions<ManagementServiceOptions>. Register the
        // defaults (24 h TTL) so the TTL tests exercise the real threshold.
        _services.AddSingleton<IOptions<ManagementServiceOptions>>(
            Options.Create(new ManagementServiceOptions()));

        // #206: route SecuredWrite audit through the REAL CentralAuditWriter (the same
        // central-direct-write path Notification Outbox dispatch + Inbound API use) so
        // these tests exercise SourceNode stamping end-to-end — actor → writer → repo.
        // The writer stamps SourceNode from the INodeIdentityProvider (central-a/central-b)
        // and persists via the captured IAuditLogRepository, so the captured rows now
        // carry a non-null SourceNode. Mirrors AuditLog.Tests CentralAuditWriterTests'
        // wiring (real writer + substituted repo + node identity).
        var nodeIdentity = Substitute.For<INodeIdentityProvider>();
        nodeIdentity.NodeName.Returns(ExpectedSourceNode);
        _services.AddSingleton<INodeIdentityProvider>(nodeIdentity);
        _services.AddSingleton<ICentralAuditWriter>(sp => new CentralAuditWriter(
            sp,
            NullLogger<CentralAuditWriter>.Instance,
            nodeIdentity: sp.GetRequiredService<INodeIdentityProvider>()));
    }

    /// <summary>
    /// Captures every <see cref="AuditEvent"/> that lands at the substituted
    /// <see cref="IAuditLogRepository.InsertIfNotExistsAsync"/> — i.e. the row AFTER the
    /// real <see cref="CentralAuditWriter"/> has stamped <c>SourceNode</c> (#206), so the
    /// captured events carry the production node-of-origin value. Audit emission is
    /// best-effort and asynchronous off the actor thread, so a short await-condition
    /// poll lets the captured list settle before assertions.
    /// </summary>
    private List<AuditEvent> CaptureAuditEvents()
    {
        var captured = new List<AuditEvent>();
        _auditRepo
            .When(r => r.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>()))
            .Do(ci => captured.Add(ci.Arg<AuditEvent>()));
        return captured;
    }

    /// <summary>Spins briefly until the captured audit list reaches <paramref name="count"/> rows.</summary>
    private static void WaitForAuditRows(List<AuditEvent> captured, int count)
    {
        var deadline = DateTime.UtcNow.AddSeconds(5);
        while (captured.Count < count && DateTime.UtcNow < deadline)
        {
            Thread.Sleep(10);
        }
    }

    private static AuditEvent SingleOfKind(List<AuditEvent> captured, AuditKind kind) =>
        Assert.Single(captured, e => e.Action == $"{AuditChannel.SecuredWrite}.{kind}");

    /// <summary>
    /// Test double for the site-write seam. <see cref="CommunicationService.WriteTagAsync"/>
    /// is virtual so the approve relay can be exercised without a live actor system;
    /// records the relayed request and returns a canned response.
    /// </summary>
    private sealed class StubCommunicationService : CommunicationService
    {
        public StubCommunicationService()
            : base(Options.Create(new CommunicationOptions()), NullLogger<CommunicationService>.Instance)
        {
        }

        public WriteTagRequest? LastRequest { get; private set; }

        public int CallCount { get; private set; }

        public Func<WriteTagRequest, WriteTagResponse> Responder { get; set; } =
            req => new WriteTagResponse(req.CorrelationId, Success: true, ErrorMessage: null, DateTimeOffset.UtcNow);

        public Exception? ThrowOnWrite { get; set; }

        public override Task<WriteTagResponse> WriteTagAsync(
            string siteId, WriteTagRequest request, CancellationToken ct = default)
        {
            CallCount++;
            LastRequest = request;
            if (ThrowOnWrite is not null)
            {
                return Task.FromException<WriteTagResponse>(ThrowOnWrite);
            }

            return Task.FromResult(Responder(request));
        }
    }

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

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

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

    void IDisposable.Dispose() => Shutdown();

    /// <summary>Wires a site whose single connection uses the given protocol.</summary>
    private void SeedSiteWithConnection(int siteId, string identifier, string connectionName, string protocol)
    {
        _siteRepo.GetSiteByIdentifierAsync(identifier, Arg.Any<CancellationToken>())
            .Returns(new Site($"Site{siteId}", identifier) { Id = siteId });
        _siteRepo.GetDataConnectionsBySiteIdAsync(siteId, Arg.Any<CancellationToken>())
            .Returns(new List<DataConnection>
            {
                new(connectionName, protocol, siteId) { Id = 100 }
            });
    }

    // ------------------------------------------------------------------------
    // Submit
    // ------------------------------------------------------------------------

    [Fact]
    public void Submit_OnNonMxGatewayConnection_ReturnsError_NoRowInserted()
    {
        SeedSiteWithConnection(1, "SITE1", "Plc1", "OpcUa");

        var actor = CreateActor();
        var envelope = Envelope(
            new SubmitSecuredWriteCommand("SITE1", "Plc1", "Tag.A", "true", "Boolean", "go"),
            "alice", "Operator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("MxGateway", response.Error);
        _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
    }

    [Fact]
    public void Submit_OnUnknownConnection_ReturnsError_NoRowInserted()
    {
        SeedSiteWithConnection(1, "SITE1", "Plc1", "MxGateway");

        var actor = CreateActor();
        var envelope = Envelope(
            new SubmitSecuredWriteCommand("SITE1", "DoesNotExist", "Tag.A", "true", "Boolean", null),
            "alice", "Operator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
    }

    [Fact]
    public void Submit_OnMxGatewayConnection_InsertsPendingRowWithOperatorUser()
    {
        SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");

        PendingSecuredWrite? inserted = null;
        _securedWriteRepo
            .When(r => r.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => inserted = ci.Arg<PendingSecuredWrite>());
        _securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
            .Returns(55L);

        var actor = CreateActor();
        var envelope = Envelope(
            new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.Setpoint", "42.5", "Double", "raise setpoint"),
            "alice", "Operator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(inserted);
        Assert.Equal("Pending", inserted!.Status);
        Assert.Equal("alice", inserted.OperatorUser);
        Assert.Equal("Mx1", inserted.ConnectionName);
        Assert.Equal("SITE1", inserted.SiteId);
        Assert.Equal("Tag.Setpoint", inserted.TagPath);
        Assert.Equal("42.5", inserted.ValueJson);
        Assert.Equal("Double", inserted.ValueType);
        Assert.Equal("raise setpoint", inserted.OperatorComment);
        // The returned DTO carries the store-assigned id and operator.
        Assert.Contains("\"id\":55", response.JsonData);
        Assert.Contains("alice", response.JsonData);
    }

    // ------------------------------------------------------------------------
    // Reject
    // ------------------------------------------------------------------------

    private PendingSecuredWrite SeedPendingWrite(long id, string operatorUser)
    {
        var row = new PendingSecuredWrite
        {
            Id = id,
            SiteId = "SITE1",
            ConnectionName = "Mx1",
            TagPath = "Tag.A",
            ValueJson = "true",
            ValueType = "Boolean",
            Status = "Pending",
            OperatorUser = operatorUser,
            SubmittedAtUtc = DateTime.UtcNow
        };
        _securedWriteRepo.GetAsync(id, Arg.Any<CancellationToken>()).Returns(row);
        // The approve handler re-asserts the MxGateway protocol AT EXECUTE (D2 / T14
        // TOCTOU guard), so the connection named on the row must still resolve to an
        // MxGateway connection at approve-time for the relay to proceed. Seed it here
        // so every approve test that should relay sees a valid MxGateway target; tests
        // exercising the reconfigured/missing-connection case re-stub this lookup.
        SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
        return row;
    }

    [Fact]
    public void Reject_BySubmittingUser_ReturnsError_StatusUnchanged()
    {
        SeedPendingWrite(7, operatorUser: "alice");

        var actor = CreateActor();
        // Same principal that submitted attempts to reject — separation of duties.
        var envelope = Envelope(new RejectSecuredWriteCommand(7, "no"), "alice", "Verifier");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        _securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
    }

    [Fact]
    public void Reject_ByDifferentUser_FlipsStatusToRejected()
    {
        SeedPendingWrite(7, operatorUser: "alice");

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new RejectSecuredWriteCommand(7, "not authorized"), "bob", "Verifier");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(updated);
        Assert.Equal("Rejected", updated!.Status);
        Assert.Equal("bob", updated.VerifierUser);
        Assert.Equal("not authorized", updated.VerifierComment);
        Assert.NotNull(updated.DecidedAtUtc);
        // Operator fields are preserved (fully-populated update).
        Assert.Equal("alice", updated.OperatorUser);
    }

    [Fact]
    public void Reject_NonPendingRow_ReturnsError()
    {
        var row = SeedPendingWrite(7, operatorUser: "alice");
        row.Status = "Approved";

        var actor = CreateActor();
        var envelope = Envelope(new RejectSecuredWriteCommand(7, null), "bob", "Verifier");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        _securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
    }

    [Fact]
    public void Reject_MissingRow_ReturnsError()
    {
        _securedWriteRepo.GetAsync(99, Arg.Any<CancellationToken>()).Returns((PendingSecuredWrite?)null);

        var actor = CreateActor();
        var envelope = Envelope(new RejectSecuredWriteCommand(99, null), "bob", "Verifier");

        actor.Tell(envelope);

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

    // ------------------------------------------------------------------------
    // List
    // ------------------------------------------------------------------------

    [Fact]
    public void List_FiltersByStatusAndSite()
    {
        _securedWriteRepo.QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(new List<PendingSecuredWrite>
            {
                new()
                {
                    Id = 3, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
                    ValueJson = "true", ValueType = "Boolean", Status = "Pending",
                    OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow
                }
            });

        var actor = CreateActor();
        // arch-review UA1: ListSecuredWrites is gated to the Secured Write
        // roles + Administrator, so use Operator here (this test asserts filter
        // forwarding, not the role gate — see ListSecuredWrites_* auth tests).
        var envelope = Envelope(new ListSecuredWritesCommand("Pending", "SITE1"), "carol", "Operator");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.Contains("\"id\":3", response.JsonData);
        Assert.Contains("alice", response.JsonData);
        _securedWriteRepo.Received(1).QueryAsync("Pending", "SITE1", 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void List_NoFilters_PassesNullsToRepository()
    {
        _securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(new List<PendingSecuredWrite>());

        var actor = CreateActor();
        // arch-review UA1: gated read — Operator satisfies the any-of gate.
        var envelope = Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void List_SiteScopedReader_ExplicitOutOfScopeSiteFilter_Unauthorized()
    {
        SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, "SITE1"), "bob", ["3"], "Verifier"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void List_SiteScopedReader_Unfiltered_QueriesOnlyPermittedIdentifiers()
    {
        _siteRepo.GetAllSitesAsync(Arg.Any<CancellationToken>()).Returns(new List<Site>
        {
            new("Site1", "SITE1") { Id = 1 },
            new("Site3", "SITE3") { Id = 3 },
        });
        _securedWriteRepo.QueryAsync(null, null, 0, 200,
                Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(new List<PendingSecuredWrite>());
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new ListSecuredWritesCommand(null, null), "bob", ["3"], "Verifier"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200,
            Arg.Is<IReadOnlyCollection<string>?>(s => s != null && s.Single() == "SITE3"),
            Arg.Any<CancellationToken>());
    }

    [Fact]
    public void List_SystemWideReader_PassesNullPermittedFilter()
    {
        _securedWriteRepo.QueryAsync(null, null, 0, 200,
                Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(new List<PendingSecuredWrite>());
        var actor = CreateActor();
        actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.Received(1).QueryAsync(null, null, 0, 200,
            Arg.Is<IReadOnlyCollection<string>?>(s => s == null),
            Arg.Any<CancellationToken>());
    }

    // ------------------------------------------------------------------------
    // Approve (execute) — Task C3
    // ------------------------------------------------------------------------

    /// <summary>Arms the CAS to succeed (a different verifier wins the race).</summary>
    private void ArmCasSuccess(long id) =>
        _securedWriteRepo.TryMarkApprovedAsync(
                id, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(true);

    [Fact]
    public void Approve_ByDifferentVerifier_RelaysWrite_FlipsStatusToExecuted()
    {
        SeedPendingWrite(7, operatorUser: "alice");
        ArmCasSuccess(7);

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

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

        // The CAS guarded the transition (exactly once).
        _securedWriteRepo.Received(1).TryMarkApprovedAsync(
            7, "bob", "approved", Arg.Any<DateTime>(), Arg.Any<CancellationToken>());

        // The write was relayed to the site MxGateway connection.
        Assert.Equal(1, _comms.CallCount);
        Assert.NotNull(_comms.LastRequest);
        Assert.Equal("Mx1", _comms.LastRequest!.ConnectionName);
        Assert.Equal("Tag.A", _comms.LastRequest.TagPath);

        Assert.NotNull(updated);
        Assert.Equal("Executed", updated!.Status);
        Assert.NotNull(updated.ExecutedAtUtc);
        Assert.Null(updated.ExecutionError);
    }

    [Fact]
    public void Approve_BySubmittingUser_ReturnsError_CasNotCalled_NoRelay()
    {
        SeedPendingWrite(7, operatorUser: "alice");

        var actor = CreateActor();
        // Same principal that submitted attempts to approve — separation of duties.
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "self"), "alice", "Verifier");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        _securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(
            default, default!, default, default, default);
        Assert.Equal(0, _comms.CallCount);
    }

    [Fact]
    public void Approve_WhenCasLosesRace_ReturnsError_NoRelay()
    {
        SeedPendingWrite(7, operatorUser: "alice");
        _securedWriteRepo.TryMarkApprovedAsync(
                7, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(false);

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "race"), "bob", "Verifier");

        actor.Tell(envelope);

        var response = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Equal("COMMAND_FAILED", response.ErrorCode);
        Assert.Contains("already decided", response.Error);
        Assert.Equal(0, _comms.CallCount);
    }

    [Fact]
    public void Approve_WhenRelayFails_FlipsStatusToFailed_WithError()
    {
        SeedPendingWrite(7, operatorUser: "alice");
        ArmCasSuccess(7);
        _comms.Responder = req =>
            new WriteTagResponse(req.CorrelationId, Success: false, ErrorMessage: "device offline", DateTimeOffset.UtcNow);

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        // The approve itself succeeds (the row was decided); the relay outcome is recorded on the row.
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(1, _comms.CallCount);
        Assert.NotNull(updated);
        Assert.Equal("Failed", updated!.Status);
        Assert.Equal("device offline", updated.ExecutionError);
        Assert.NotNull(updated.ExecutedAtUtc);
    }

    [Fact]
    public void Approve_WhenRelayThrows_FlipsStatusToFailed_WithExceptionMessage()
    {
        SeedPendingWrite(7, operatorUser: "alice");
        ArmCasSuccess(7);
        _comms.ThrowOnWrite = new InvalidOperationException("transport down");

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.NotNull(updated);
        Assert.Equal("Failed", updated!.Status);
        Assert.Contains("transport down", updated.ExecutionError);
    }

    [Fact]
    public void Approve_UnknownValueType_FlipsStatusToFailed_NoRelay()
    {
        var row = SeedPendingWrite(7, operatorUser: "alice");
        row.ValueType = "Bogus";
        ArmCasSuccess(7);

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(0, _comms.CallCount);
        Assert.NotNull(updated);
        Assert.Equal("Failed", updated!.Status);
        Assert.Equal("unknown value type", updated.ExecutionError);
    }

    [Fact]
    public void Approve_WhenConnectionNoLongerMxGateway_FlipsStatusToFailed_NoRelay()
    {
        // D2 / T14 TOCTOU guard: the MxGateway protocol is re-asserted AT EXECUTE, not
        // only at submit. The named connection was reconfigured/recreated as a
        // non-MxGateway (OPC UA) connection between submit and approval; relaying then
        // would execute the secured write against a non-MxGateway adapter, violating the
        // feature's core safety invariant. The row must fail deterministically with the
        // protocol error and NO relay — even though the CAS already won.
        SeedPendingWrite(7, operatorUser: "alice");
        ArmCasSuccess(7);
        // Re-stub the approve-time connection lookup to a non-MxGateway protocol,
        // overriding the MxGateway seed inside SeedPendingWrite.
        SeedSiteWithConnection(1, "SITE1", "Mx1", "OpcUa");
        var captured = CaptureAuditEvents();

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // No relay — the connection is no longer an MxGateway target.
        Assert.Equal(0, _comms.CallCount);
        Assert.NotNull(updated);
        Assert.Equal("Failed", updated!.Status);
        Assert.NotNull(updated.ExecutedAtUtc);
        Assert.Contains("no longer an MxGateway connection", updated.ExecutionError);

        // The Execute audit row records the failure (canonical Failure outcome).
        WaitForAuditRows(captured, 2);
        var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
        Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
    }

    [Fact]
    public void Approve_WhenConnectionDeleted_FlipsStatusToFailed_NotFound_NoRelay()
    {
        // TOCTOU companion: the connection named on the row was deleted between submit
        // and approval. The re-load at execute returns no match → fail with "not found"
        // and no relay.
        SeedPendingWrite(7, operatorUser: "alice");
        ArmCasSuccess(7);
        // Re-stub the approve-time lookup so the site resolves but exposes no connections.
        _siteRepo.GetDataConnectionsBySiteIdAsync(1, Arg.Any<CancellationToken>())
            .Returns(new List<DataConnection>());

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(0, _comms.CallCount);
        Assert.NotNull(updated);
        Assert.Equal("Failed", updated!.Status);
        Assert.Contains("not found", updated.ExecutionError);
    }

    // ------------------------------------------------------------------------
    // List paging — Task 17 (arch-review P3)
    // ------------------------------------------------------------------------

    [Fact]
    public void List_WithSkipTake_ForwardsPagingAndReturnsTotalCount()
    {
        _securedWriteRepo.QueryAsync(null, null, 10, 25, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(new List<PendingSecuredWrite>());
        _securedWriteRepo.CountAsync(null, null, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>()).Returns(87);

        var actor = CreateActor();
        actor.Tell(Envelope(new ListSecuredWritesCommand(null, null, Skip: 10, Take: 25), "carol", "Operator"));

        var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.Received(1).QueryAsync(null, null, 10, 25, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
        _securedWriteRepo.Received(1).CountAsync(null, null, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>());
        Assert.Contains("\"totalCount\":87", resp.JsonData);
    }

    // ------------------------------------------------------------------------
    // Opportunistic expiry sweep on list — Task 16 (arch-review S2)
    // ------------------------------------------------------------------------

    [Fact]
    public void List_SweepsStalePendingRows_MarksExpired_CallsTryMarkExpiredOnceForStaleOnly()
    {
        var fresh = new PendingSecuredWrite
        {
            Id = 1, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.A",
            ValueJson = "true", ValueType = "Boolean", Status = "Pending",
            OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-1)
        };
        var stale = new PendingSecuredWrite
        {
            Id = 2, SiteId = "SITE1", ConnectionName = "Mx1", TagPath = "Tag.B",
            ValueJson = "false", ValueType = "Boolean", Status = "Pending",
            OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow.AddHours(-25) // TTL default 24h
        };
        _securedWriteRepo.QueryAsync(null, null, 0, 200, Arg.Any<IReadOnlyCollection<string>?>(), Arg.Any<CancellationToken>())
            .Returns(new List<PendingSecuredWrite> { fresh, stale });
        _securedWriteRepo.TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(true);

        var actor = CreateActor();
        actor.Tell(Envelope(new ListSecuredWritesCommand(null, null), "carol", "Operator"));

        var resp = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // The stale row surfaces as Expired in the projection; the fresh row stays Pending.
        Assert.Contains("\"id\":2", resp.JsonData);
        Assert.Contains("Expired", resp.JsonData);
        // Exactly one CAS — only the overdue row.
        _securedWriteRepo.Received(1).TryMarkExpiredAsync(2, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
        _securedWriteRepo.DidNotReceive().TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
    }

    // ------------------------------------------------------------------------
    // Server-side TTL expiry — Task 15 (arch-review S2)
    // ------------------------------------------------------------------------

    [Fact]
    public void Approve_PendingRowOlderThanTtl_ExpiresInsteadOfExecuting()
    {
        var row = SeedPendingWrite(1, operatorUser: "alice");
        row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25); // TTL default 24h
        _securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(true);

        var actor = CreateActor();
        actor.Tell(Envelope(new ApproveSecuredWriteCommand(1, null), "bob", "Verifier"));

        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
        _securedWriteRepo.DidNotReceive().TryMarkApprovedAsync(
            Arg.Any<long>(), Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
        // The stale setpoint is NEVER relayed to the site.
        Assert.Equal(0, _comms.CallCount);
        _securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void Reject_PendingRowOlderThanTtl_ExpiresInsteadOfRejecting()
    {
        var row = SeedPendingWrite(1, operatorUser: "alice");
        row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-25);
        _securedWriteRepo.TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(true);

        var actor = CreateActor();
        actor.Tell(Envelope(new RejectSecuredWriteCommand(1, null), "bob", "Verifier"));

        var resp = ExpectMsg<ManagementError>(TimeSpan.FromSeconds(5));
        Assert.Contains("expired", resp.Error, StringComparison.OrdinalIgnoreCase);
        // The reject path never overwrites the row (the CAS expiry owns the transition).
        _securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
        _securedWriteRepo.Received(1).TryMarkExpiredAsync(1, Arg.Any<DateTime>(), Arg.Any<CancellationToken>());
    }

    [Fact]
    public void Approve_FreshPendingRow_StillExecutes()
    {
        // Submitted 1h ago → within the 24h TTL, so the existing approve path is unchanged.
        var row = SeedPendingWrite(7, operatorUser: "alice");
        row.SubmittedAtUtc = DateTime.UtcNow.AddHours(-1);
        ArmCasSuccess(7);

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        actor.Tell(Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier"));

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkExpiredAsync(default, default, default);
        Assert.Equal(1, _comms.CallCount);
        Assert.NotNull(updated);
        Assert.Equal("Executed", updated!.Status);
    }

    // ------------------------------------------------------------------------
    // Audit emission (T14b — SecuredWrite AuditLog rows)
    // ------------------------------------------------------------------------

    /// <summary>
    /// The correlation id stamped on a secured-write audit row encodes the row's long Id
    /// as a big-endian value in the final 8 bytes of an otherwise-zero Guid — mirrors the
    /// production encoding in <c>ManagementActor.SecuredWriteCorrelation</c>.
    /// </summary>
    private static Guid CorrelationFor(long id)
    {
        Span<byte> bytes = stackalloc byte[16];
        System.Buffers.Binary.BinaryPrimitives.WriteInt64BigEndian(bytes[8..], id);
        return new Guid(bytes);
    }

    [Fact]
    public void Submit_EmitsExactlyOneSubmitAuditRow_WithOperatorAndCorrelation()
    {
        SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
        _securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
            .Returns(55L);
        var captured = CaptureAuditEvents();

        var actor = CreateActor();
        var envelope = Envelope(
            new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.Setpoint", "42.5", "Double", "raise"),
            "alice", "Operator");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        WaitForAuditRows(captured, 1);

        var evt = SingleOfKind(captured, AuditKind.SecuredWriteSubmit);
        Assert.Equal("alice", evt.Actor);
        Assert.Equal(CorrelationFor(55L), evt.CorrelationId);
        Assert.Equal("SecuredWrite", evt.Category);
        Assert.Equal("SITE1/Mx1/Tag.Setpoint", evt.Target);
        // #206: the Submit row is stamped with the writing central node's identifier
        // (it routed through the real CentralAuditWriter), not the legacy null SourceNode.
        Assert.Equal(ExpectedSourceNode, evt.SourceNode);
        // Exactly one row — no stray duplicates.
        Assert.Single(captured);
    }

    [Fact]
    public void Reject_EmitsOneRejectAuditRow_WithVerifier()
    {
        SeedPendingWrite(7, operatorUser: "alice");
        var captured = CaptureAuditEvents();

        var actor = CreateActor();
        var envelope = Envelope(new RejectSecuredWriteCommand(7, "not authorized"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        WaitForAuditRows(captured, 1);

        var evt = SingleOfKind(captured, AuditKind.SecuredWriteReject);
        Assert.Equal("bob", evt.Actor);
        Assert.Equal(CorrelationFor(7L), evt.CorrelationId);
        // #206: Reject row carries the writing central node's identifier.
        Assert.Equal(ExpectedSourceNode, evt.SourceNode);
        Assert.Single(captured);
    }

    [Fact]
    public void Approve_Success_EmitsApproveThenExecuteDeliveredAuditRows()
    {
        SeedPendingWrite(7, operatorUser: "alice");
        ArmCasSuccess(7);
        var captured = CaptureAuditEvents();

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        WaitForAuditRows(captured, 2);

        var approve = SingleOfKind(captured, AuditKind.SecuredWriteApprove);
        Assert.Equal("bob", approve.Actor);
        Assert.Equal(CorrelationFor(7L), approve.CorrelationId);
        // #206: Approve + Execute rows both carry the writing central node's identifier.
        Assert.Equal(ExpectedSourceNode, approve.SourceNode);

        var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
        Assert.Equal("bob", execute.Actor);
        Assert.Equal(CorrelationFor(7L), execute.CorrelationId);
        Assert.Equal(ExpectedSourceNode, execute.SourceNode);
        // Delivered outcome → canonical Success.
        Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Success, execute.Outcome);
        Assert.Equal(2, captured.Count);
    }

    [Fact]
    public void Submit_AuditInsertThrows_StillSucceeds_RowStillPersisted()
    {
        SeedSiteWithConnection(1, "SITE1", "Mx1", "MxGateway");
        PendingSecuredWrite? inserted = null;
        _securedWriteRepo
            .When(r => r.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => inserted = ci.Arg<PendingSecuredWrite>());
        _securedWriteRepo.AddAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>())
            .Returns(55L);
        // Best-effort: a thrown audit insert must NOT abort the secured-write action.
        // #206: the throw now happens behind the real CentralAuditWriter (which swallows
        // it), so this still exercises the standing "audit failure never aborts the
        // action" invariant — now through the central-direct-write path.
        _auditRepo.InsertIfNotExistsAsync(Arg.Any<AuditEvent>(), Arg.Any<CancellationToken>())
            .Returns(Task.FromException(new InvalidOperationException("audit db down")));

        var actor = CreateActor();
        var envelope = Envelope(
            new SubmitSecuredWriteCommand("SITE1", "Mx1", "Tag.A", "true", "Boolean", null),
            "alice", "Operator");

        actor.Tell(envelope);

        // The action still succeeds and the row is still persisted despite the audit failure.
        var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        Assert.Equal(envelope.CorrelationId, response.CorrelationId);
        Assert.NotNull(inserted);
        Assert.Equal("Pending", inserted!.Status);
        Assert.Equal("alice", inserted.OperatorUser);
    }

    [Fact]
    public void Approve_CorruptListValueJson_FlipsStatusToFailed_DecodeError_NoRelay()
    {
        // C3 robustness fix: a List-typed value with corrupt JSON must not throw
        // out of the handler and leave the row stuck Approved — it is contained,
        // the row flips to Failed with a "value decode error", and no relay occurs.
        var row = SeedPendingWrite(7, operatorUser: "alice");
        row.ValueType = "List";
        row.ValueJson = "{not valid json";
        ArmCasSuccess(7);
        var captured = CaptureAuditEvents();

        PendingSecuredWrite? updated = null;
        _securedWriteRepo
            .When(r => r.UpdateAsync(Arg.Any<PendingSecuredWrite>(), Arg.Any<CancellationToken>()))
            .Do(ci => updated = ci.Arg<PendingSecuredWrite>());

        var actor = CreateActor();
        var envelope = Envelope(new ApproveSecuredWriteCommand(7, "approved"), "bob", "Verifier");

        actor.Tell(envelope);

        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
        // No relay — the value never decoded.
        Assert.Equal(0, _comms.CallCount);
        Assert.NotNull(updated);
        Assert.Equal("Failed", updated!.Status);
        Assert.NotNull(updated.ExecutedAtUtc);
        Assert.StartsWith("value decode error:", updated.ExecutionError);

        // The Execute audit row records the failure (canonical Failure outcome).
        WaitForAuditRows(captured, 2);
        var execute = SingleOfKind(captured, AuditKind.SecuredWriteExecute);
        Assert.Equal(ZB.MOM.WW.Audit.AuditOutcome.Failure, execute.Outcome);
    }

    // ------------------------------------------------------------------------
    // Site scope on submit / approve / reject (arch-review R2 N3)
    // ------------------------------------------------------------------------

    [Fact]
    public void Submit_SiteScopedOperator_OutOfScopeSite_Unauthorized_NoRowInserted()
    {
        SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(
            new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
            "alice", ["3"], "Operator"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
    }

    [Fact]
    public void Submit_SiteScopedOperator_InScopeSite_Succeeds()
    {
        SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(
            new SubmitSecuredWriteCommand("SITE1", "Gw1", "Tag.A", "true", "Boolean", "go"),
            "alice", ["1"], "Operator"));
        ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
    }

    [Fact]
    public void Approve_SiteScopedVerifier_OutOfScopeSite_Unauthorized_NeverRelays()
    {
        SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
        _securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
        {
            Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
            ValueJson = "true", ValueType = "Boolean", Status = "Pending",
            OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
        });
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "bob", ["3"], "Verifier"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.DidNotReceiveWithAnyArgs().TryMarkApprovedAsync(default, default!, default, default, default);
        Assert.Equal(0, _comms.CallCount); // the device write is NEVER relayed
    }

    [Fact]
    public void Reject_SiteScopedVerifier_OutOfScopeSite_Unauthorized()
    {
        SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
        _securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
        {
            Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
            ValueJson = "true", ValueType = "Boolean", Status = "Pending",
            OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
        });
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new RejectSecuredWriteCommand(42, "no"), "bob", ["3"], "Verifier"));
        ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
        _securedWriteRepo.DidNotReceiveWithAnyArgs().UpdateAsync(default!, default);
    }

    [Fact]
    public void Approve_ScopedAdministrator_BypassesSiteScope()
    {
        // A scoped Verifier who is ALSO an Administrator bypasses site scope
        // (EnforceSiteScope admin bypass, ManagementActor.cs:499) — the approve
        // proceeds past the scope gate rather than returning ManagementUnauthorized.
        SeedSiteWithConnection(1, "SITE1", "Gw1", "MxGateway");
        _securedWriteRepo.GetAsync(42, Arg.Any<CancellationToken>()).Returns(new PendingSecuredWrite
        {
            Id = 42, SiteId = "SITE1", ConnectionName = "Gw1", TagPath = "Tag.A",
            ValueJson = "true", ValueType = "Boolean", Status = "Pending",
            OperatorUser = "alice", SubmittedAtUtc = DateTime.UtcNow,
        });
        _securedWriteRepo.TryMarkApprovedAsync(
                42, Arg.Any<string>(), Arg.Any<string?>(), Arg.Any<DateTime>(), Arg.Any<CancellationToken>())
            .Returns(true);
        var actor = CreateActor();
        actor.Tell(ScopedEnvelope(new ApproveSecuredWriteCommand(42, "ok"), "carol", ["3"], "Verifier", "Administrator"));
        var response = ExpectMsg<object>(TimeSpan.FromSeconds(5));
        Assert.IsNotType<ManagementUnauthorized>(response);
    }
}
