using Google.Protobuf;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Health;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Notification;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Audit;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

namespace ZB.MOM.WW.ScadaBridge.Communication.Tests.Grpc;

/// <summary>
/// Round-trip golden tests for <see cref="CentralControlDtoMapper"/> — the DTO bridge
/// for the seven <c>CentralControlService</c> RPCs (Phase 1A of the ClusterClient→gRPC
/// migration).
/// </summary>
/// <remarks>
/// <para>
/// Every message gets a pair: a FULLY-POPULATED case that proves no field is dropped,
/// and a MINIMAL case that proves every nullable/optional/empty-collection member comes
/// back as null-or-empty rather than as a zero-valued stand-in. The minimal cases are
/// the ones that matter: a per-field unit test happily passes while a whole optional
/// branch is silently never written, which is exactly the class of bug the round-trip
/// guard in PLAN-05 T8 caught five times over.
/// </para>
/// <para>
/// When a round-trip here fails, the mapper is wrong — not the test.
/// </para>
/// </remarks>
public class CentralControlDtoMapperTests
{
    private static readonly DateTimeOffset SampleInstant =
        new(2026, 7, 22, 9, 30, 15, 250, TimeSpan.Zero);

    // -----------------------------------------------------------------------
    // NotificationSubmit / Ack
    // -----------------------------------------------------------------------

    [Fact]
    public void NotificationSubmit_RoundTrip_FullyPopulated_PreservesEveryField()
    {
        var original = new NotificationSubmit(
            NotificationId: Guid.NewGuid().ToString(),
            ListName: "plant-ops",
            Subject: "Tank 4 overfill",
            Body: "Level exceeded 95% for 5 minutes.",
            SourceSiteId: "site-a",
            SourceInstanceId: "Tank04",
            SourceScript: "OnLevelHigh",
            SiteEnqueuedAt: SampleInstant,
            OriginExecutionId: Guid.NewGuid(),
            OriginParentExecutionId: Guid.NewGuid(),
            SourceNode: "node-b");

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        // Every member is a scalar, so record value-equality is a true deep compare.
        Assert.Equal(original, roundTripped);
    }

    [Fact]
    public void NotificationSubmit_RoundTrip_AllOptionalsNull_StayNull()
    {
        var original = new NotificationSubmit(
            NotificationId: Guid.NewGuid().ToString(),
            ListName: "plant-ops",
            Subject: "s",
            Body: "b",
            SourceSiteId: "site-a",
            SourceInstanceId: null,
            SourceScript: null,
            SiteEnqueuedAt: SampleInstant,
            OriginExecutionId: null,
            OriginParentExecutionId: null,
            SourceNode: null);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(original, roundTripped);
        Assert.Null(roundTripped.SourceInstanceId);
        Assert.Null(roundTripped.SourceScript);
        Assert.Null(roundTripped.OriginExecutionId);
        Assert.Null(roundTripped.OriginParentExecutionId);
        Assert.Null(roundTripped.SourceNode);
    }

    [Fact]
    public void NotificationSubmit_NonUtcOffset_NormalizesToTheSameInstant()
    {
        // Documented contract: a protobuf Timestamp is an instant, so the offset
        // component of a DateTimeOffset does not survive. Every producer stamps UTC
        // (Notify.Send uses DateTimeOffset.UtcNow), so the instant is what matters.
        var melbourne = new DateTimeOffset(2026, 7, 22, 19, 30, 15, TimeSpan.FromHours(10));

        var original = new NotificationSubmit(
            "id", "list", "s", "b", "site-a", null, null, melbourne);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(melbourne.UtcDateTime, roundTripped.SiteEnqueuedAt.UtcDateTime);
        Assert.Equal(TimeSpan.Zero, roundTripped.SiteEnqueuedAt.Offset);
    }

    [Fact]
    public void NotificationSubmitAck_RoundTrip_Accepted_And_Rejected()
    {
        var accepted = new NotificationSubmitAck("n1", Accepted: true, Error: null);
        var rejected = new NotificationSubmitAck("n2", Accepted: false, Error: "list not found");

        Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))));
        Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected))));
        Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error);
    }

    // -----------------------------------------------------------------------
    // NotificationStatusQuery / Response
    // -----------------------------------------------------------------------

    [Fact]
    public void NotificationStatusQuery_RoundTrip_PreservesEveryField()
    {
        var original = new NotificationStatusQuery("corr-1", Guid.NewGuid().ToString());

        Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
    }

    [Fact]
    public void NotificationStatusResponse_RoundTrip_FullyPopulated_PreservesEveryField()
    {
        var original = new NotificationStatusResponse(
            CorrelationId: "corr-1",
            Found: true,
            Status: "Delivered",
            RetryCount: 3,
            LastError: "smtp 421",
            DeliveredAt: SampleInstant);

        Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
    }

    [Fact]
    public void NotificationStatusResponse_RoundTrip_NotFound_LeavesOptionalsNull()
    {
        var original = new NotificationStatusResponse(
            CorrelationId: "corr-1",
            Found: false,
            Status: "Unknown",
            RetryCount: 0,
            LastError: null,
            DeliveredAt: null);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(original, roundTripped);
        Assert.Null(roundTripped.LastError);
        Assert.Null(roundTripped.DeliveredAt);
    }

    // -----------------------------------------------------------------------
    // Audit ingest envelopes (rows delegate to AuditEventDtoMapper / SiteCallDtoMapper)
    // -----------------------------------------------------------------------

    [Fact]
    public void IngestAuditEventsCommand_RoundTrip_PreservesOrderAndRows()
    {
        var first = NewAuditEvent(sourceScript: "OnDemand");
        var second = NewAuditEvent(sourceScript: "OnTrigger");
        var original = new IngestAuditEventsCommand([first, second]);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(2, roundTripped.Events.Count);
        Assert.Equal(first.AsRow().EventId, roundTripped.Events[0].AsRow().EventId);
        Assert.Equal("OnDemand", roundTripped.Events[0].AsRow().SourceScript);
        Assert.Equal(second.AsRow().EventId, roundTripped.Events[1].AsRow().EventId);
        Assert.Equal("OnTrigger", roundTripped.Events[1].AsRow().SourceScript);
    }

    [Fact]
    public void IngestAuditEventsCommand_RoundTrip_EmptyBatch_StaysEmpty()
    {
        var roundTripped = CentralControlDtoMapper.FromDto(
            Wire(CentralControlDtoMapper.ToDto(new IngestAuditEventsCommand([]))));

        Assert.Empty(roundTripped.Events);
    }

    [Fact]
    public void IngestCachedTelemetryCommand_RoundTrip_PreservesBothHalvesOfEachPacket()
    {
        var audit = NewAuditEvent(sourceScript: "OnDemand");
        var siteCall = NewSiteCall();
        var original = new IngestCachedTelemetryCommand([new CachedTelemetryEntry(audit, siteCall)]);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        var entry = Assert.Single(roundTripped.Entries);
        Assert.Equal(audit.AsRow().EventId, entry.Audit.AsRow().EventId);

        // IngestedAtUtc is central-set inside the dual-write transaction and is
        // deliberately off the wire, so it is the one member excluded from the compare.
        Assert.Equal(siteCall with { IngestedAtUtc = default }, entry.SiteCall with { IngestedAtUtc = default });
    }

    [Fact]
    public void IngestCachedTelemetryCommand_RoundTrip_NullableSiteCallFields_StayNull()
    {
        var siteCall = NewSiteCall() with
        {
            SourceNode = null,
            LastError = null,
            HttpStatus = null,
            TerminalAtUtc = null,
        };
        var original = new IngestCachedTelemetryCommand(
            [new CachedTelemetryEntry(NewAuditEvent(), siteCall)]);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        var entry = Assert.Single(roundTripped.Entries);
        Assert.Null(entry.SiteCall.SourceNode);
        Assert.Null(entry.SiteCall.LastError);
        Assert.Null(entry.SiteCall.HttpStatus);
        Assert.Null(entry.SiteCall.TerminalAtUtc);
    }

    [Fact]
    public void IngestCachedTelemetryCommand_RoundTrip_EmptyBatch_StaysEmpty()
    {
        var roundTripped = CentralControlDtoMapper.FromDto(
            Wire(CentralControlDtoMapper.ToDto(new IngestCachedTelemetryCommand([]))));

        Assert.Empty(roundTripped.Entries);
    }

    [Fact]
    public void IngestAck_RoundTrip_PreservesIdsAndOrder()
    {
        var ids = new[] { Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid() };

        var roundTripped = CentralControlDtoMapper.FromIngestAck(
            Wire(CentralControlDtoMapper.ToIngestAck(ids)));

        Assert.Equal(ids, roundTripped);
    }

    [Fact]
    public void IngestAck_RoundTrip_NothingAccepted_StaysEmpty()
    {
        // An empty ack is meaningful — "zero rows persisted" leaves the site's rows
        // Pending — so it must not be indistinguishable from a dropped field.
        Assert.Empty(CentralControlDtoMapper.FromIngestAck(
            Wire(CentralControlDtoMapper.ToIngestAck([]))));
    }

    // -----------------------------------------------------------------------
    // Startup reconciliation
    // -----------------------------------------------------------------------

    [Fact]
    public void ReconcileSiteRequest_RoundTrip_PreservesInventoryMap()
    {
        var original = new ReconcileSiteRequest(
            SiteIdentifier: "site-a",
            NodeId: "node-a",
            LocalNameToRevisionHash: new Dictionary<string, string>
            {
                ["Plant.Tank04"] = "hash-1",
                ["Plant.Pump01"] = "hash-2",
            });

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(original.SiteIdentifier, roundTripped.SiteIdentifier);
        Assert.Equal(original.NodeId, roundTripped.NodeId);
        Assert.Equal(
            original.LocalNameToRevisionHash.OrderBy(kv => kv.Key),
            roundTripped.LocalNameToRevisionHash.OrderBy(kv => kv.Key));
    }

    [Fact]
    public void ReconcileSiteRequest_RoundTrip_EmptyInventory_StaysEmpty()
    {
        // A node with nothing deployed is the normal first-boot case, and central
        // must see an empty inventory rather than a missing one.
        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(
            new ReconcileSiteRequest("site-a", "node-a", new Dictionary<string, string>()))));

        Assert.Empty(roundTripped.LocalNameToRevisionHash);
    }

    [Fact]
    public void ReconcileSiteResponse_RoundTrip_PreservesGapOrphansAndUrl()
    {
        var original = new ReconcileSiteResponse(
            Gap:
            [
                new ReconcileGapItem("Plant.Tank04", "dep-1", "hash-1", IsEnabled: true, "token-1"),
                new ReconcileGapItem("Plant.Pump01", "dep-2", "hash-2", IsEnabled: false, "token-2"),
            ],
            OrphanNames: ["Plant.Retired01", "Plant.Retired02"],
            CentralFetchBaseUrl: "http://scadabridge-central-a:5000");

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(original.Gap, roundTripped.Gap);
        Assert.Equal(original.OrphanNames, roundTripped.OrphanNames);
        Assert.Equal(original.CentralFetchBaseUrl, roundTripped.CentralFetchBaseUrl);
    }

    [Fact]
    public void ReconcileSiteResponse_RoundTrip_NoGap_StaysEmpty()
    {
        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(
            new ReconcileSiteResponse([], [], "http://central:5000"))));

        Assert.Empty(roundTripped.Gap);
        Assert.Empty(roundTripped.OrphanNames);
    }

    // -----------------------------------------------------------------------
    // Site health
    // -----------------------------------------------------------------------

    [Fact]
    public void SiteHealthReport_RoundTrip_FullyPopulated_PreservesEveryField()
    {
        var original = FullyPopulatedHealthReport();

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        AssertHealthReportsEqual(original, roundTripped);
    }

    [Fact]
    public void SiteHealthReport_RoundTrip_MinimalReport_LeavesEveryOptionalNullOrEmpty()
    {
        // The shape a producer emits before any reporter has run: no endpoints map, no
        // tag-quality map, no cluster-node list, no audit backlog, no nullable gauges.
        var original = new SiteHealthReport(
            SiteId: "site-a",
            SequenceNumber: 1,
            ReportTimestamp: SampleInstant,
            DataConnectionStatuses: new Dictionary<string, ConnectionHealth>(),
            TagResolutionCounts: new Dictionary<string, TagResolutionStatus>(),
            ScriptErrorCount: 0,
            AlarmEvaluationErrorCount: 0,
            StoreAndForwardBufferDepths: new Dictionary<string, int>(),
            DeadLetterCount: 0,
            DeployedInstanceCount: 0,
            EnabledInstanceCount: 0,
            DisabledInstanceCount: 0);

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        AssertHealthReportsEqual(original, roundTripped);

        Assert.Empty(roundTripped.DataConnectionStatuses);
        Assert.Empty(roundTripped.TagResolutionCounts);
        Assert.Empty(roundTripped.StoreAndForwardBufferDepths);
        Assert.Null(roundTripped.DataConnectionEndpoints);
        Assert.Null(roundTripped.DataConnectionTagQuality);
        Assert.Null(roundTripped.ClusterNodes);
        Assert.Null(roundTripped.SiteAuditBacklog);
        Assert.Null(roundTripped.OldestParkedMessageAgeSeconds);
        Assert.Null(roundTripped.ScriptOldestBusyAgeSeconds);
        Assert.Null(roundTripped.LocalDbReplicationConnected);
        Assert.Null(roundTripped.LocalDbOplogBacklog);
    }

    [Fact]
    public void SiteHealthReport_RoundTrip_EmptyNullableCollections_StayEmptyNotNull()
    {
        // The distinction the three wrapper messages exist for. Null means "this node
        // does not report the signal"; empty means "it reports it, and there is
        // nothing in it". proto3 cannot express presence on repeated/map fields, so
        // collapsing one into the other here would be invisible until an operator
        // misread the health page.
        var original = FullyPopulatedHealthReport() with
        {
            DataConnectionEndpoints = new Dictionary<string, string>(),
            DataConnectionTagQuality = new Dictionary<string, TagQualityCounts>(),
            ClusterNodes = [],
        };

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.NotNull(roundTripped.DataConnectionEndpoints);
        Assert.Empty(roundTripped.DataConnectionEndpoints);
        Assert.NotNull(roundTripped.DataConnectionTagQuality);
        Assert.Empty(roundTripped.DataConnectionTagQuality);
        Assert.NotNull(roundTripped.ClusterNodes);
        Assert.Empty(roundTripped.ClusterNodes);
    }

    [Fact]
    public void SiteHealthReport_RoundTrip_FalseAndZeroGauges_StayFalseAndZero()
    {
        // The mirror of the null case: LocalDbReplicationConnected=false ("configured
        // but disconnected") and LocalDbOplogBacklog=0 ("healthy, nothing queued") are
        // real values that must not decay into null on the wire.
        var original = FullyPopulatedHealthReport() with
        {
            LocalDbReplicationConnected = false,
            LocalDbOplogBacklog = 0,
            OldestParkedMessageAgeSeconds = 0d,
            ScriptOldestBusyAgeSeconds = 0d,
        };

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.False(roundTripped.LocalDbReplicationConnected);
        Assert.Equal(0L, roundTripped.LocalDbOplogBacklog);
        Assert.Equal(0d, roundTripped.OldestParkedMessageAgeSeconds);
        Assert.Equal(0d, roundTripped.ScriptOldestBusyAgeSeconds);
    }

    [Fact]
    public void SiteHealthReport_RoundTrip_BacklogWithEmptyQueue_KeepsNullOldestPending()
    {
        var original = FullyPopulatedHealthReport() with
        {
            SiteAuditBacklog = new SiteAuditBacklogSnapshot(0, null, 4096),
        };

        var roundTripped = CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original)));

        Assert.Equal(new SiteAuditBacklogSnapshot(0, null, 4096), roundTripped.SiteAuditBacklog);
    }

    [Fact]
    public void SiteHealthReportAck_RoundTrip_Accepted_And_Rejected()
    {
        var accepted = new SiteHealthReportAck("site-a", 42, Accepted: true);
        var rejected = new SiteHealthReportAck("site-a", 43, Accepted: false, Error: "aggregator down");

        Assert.Equal(accepted, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))));
        Assert.Equal(rejected, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(rejected))));
        Assert.Null(CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(accepted))).Error);
    }

    // -----------------------------------------------------------------------
    // Heartbeat + ConnectionHealth enum
    // -----------------------------------------------------------------------

    [Theory]
    [InlineData(true)]
    [InlineData(false)]
    public void HeartbeatMessage_RoundTrip_PreservesEveryField(bool isActive)
    {
        var original = new HeartbeatMessage("site-a", "scadabridge-site-a-node-b", isActive, SampleInstant);

        Assert.Equal(original, CentralControlDtoMapper.FromDto(Wire(CentralControlDtoMapper.ToDto(original))));
    }

    [Theory]
    [InlineData(ConnectionHealth.Connected)]
    [InlineData(ConnectionHealth.Disconnected)]
    [InlineData(ConnectionHealth.Connecting)]
    [InlineData(ConnectionHealth.Error)]
    public void ConnectionHealth_RoundTrip_EveryValueSurvives(ConnectionHealth health)
    {
        Assert.Equal(health, CentralControlDtoMapper.FromDto(CentralControlDtoMapper.ToDto(health)));
    }

    [Fact]
    public void ConnectionHealth_Unspecified_DecodesToErrorNotConnected()
    {
        // Fail-safe direction: a wire value this build does not recognise must never
        // render as "healthy" on the central health page.
        Assert.Equal(
            ConnectionHealth.Error,
            CentralControlDtoMapper.FromDto(ConnectionHealthEnum.ConnectionHealthUnspecified));
    }

    [Fact]
    public void ConnectionHealth_EveryEnumMemberIsMapped()
    {
        // Guards the ArgumentOutOfRangeException path: adding a ConnectionHealth value
        // without extending the mapper should fail here, not silently on a rig.
        foreach (var health in Enum.GetValues<ConnectionHealth>())
        {
            var wire = CentralControlDtoMapper.ToDto(health);
            Assert.NotEqual(ConnectionHealthEnum.ConnectionHealthUnspecified, wire);
        }
    }

    // -----------------------------------------------------------------------
    // Fixtures
    // -----------------------------------------------------------------------

    private static SiteHealthReport FullyPopulatedHealthReport() =>
        new(
            SiteId: "site-a",
            SequenceNumber: 987654321L,
            ReportTimestamp: SampleInstant,
            DataConnectionStatuses: new Dictionary<string, ConnectionHealth>
            {
                ["opc-main"] = ConnectionHealth.Connected,
                ["mx-gateway"] = ConnectionHealth.Error,
                ["opc-backup"] = ConnectionHealth.Connecting,
                ["legacy"] = ConnectionHealth.Disconnected,
            },
            TagResolutionCounts: new Dictionary<string, TagResolutionStatus>
            {
                ["opc-main"] = new(TotalSubscribed: 120, SuccessfullyResolved: 118),
            },
            ScriptErrorCount: 4,
            AlarmEvaluationErrorCount: 2,
            StoreAndForwardBufferDepths: new Dictionary<string, int>
            {
                ["erp"] = 17,
                ["mes"] = 0,
            },
            DeadLetterCount: 5,
            DeployedInstanceCount: 30,
            EnabledInstanceCount: 28,
            DisabledInstanceCount: 2,
            NodeRole: "Active",
            NodeHostname: "scadabridge-site-a-node-a",
            DataConnectionEndpoints: new Dictionary<string, string>
            {
                ["opc-main"] = "opc.tcp://opcua:4840",
            },
            DataConnectionTagQuality: new Dictionary<string, TagQualityCounts>
            {
                ["opc-main"] = new(Good: 100, Bad: 3, Uncertain: 15),
            },
            ParkedMessageCount: 6,
            ClusterNodes:
            [
                new NodeStatus("scadabridge-site-a-node-a", IsOnline: true, "Active"),
                new NodeStatus("scadabridge-site-a-node-b", IsOnline: false, "Standby"),
            ],
            SiteAuditWriteFailures: 7,
            AuditRedactionFailure: 8,
            SiteAuditBacklog: new SiteAuditBacklogSnapshot(
                PendingCount: 42,
                OldestPendingUtc: new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc),
                OnDiskBytes: 1_234_567L),
            SiteEventLogWriteFailures: 9L,
            OldestParkedMessageAgeSeconds: 3600.5d)
        {
            ScriptQueueDepth = 11,
            ScriptBusyThreads = 3,
            ScriptOldestBusyAgeSeconds = 12.25d,
            LocalDbReplicationConnected = true,
            LocalDbOplogBacklog = 250L,
        };

    /// <summary>
    /// Compares two reports member by member. <see cref="SiteHealthReport"/> is a record,
    /// but its dictionary/list members compare by reference under record equality, so
    /// <c>Assert.Equal(a, b)</c> would pass trivially for the scalars and never look
    /// inside the collections — the exact blind spot these goldens exist to close.
    /// </summary>
    private static void AssertHealthReportsEqual(SiteHealthReport expected, SiteHealthReport actual)
    {
        Assert.Equal(expected.SiteId, actual.SiteId);
        Assert.Equal(expected.SequenceNumber, actual.SequenceNumber);
        Assert.Equal(expected.ReportTimestamp, actual.ReportTimestamp);
        Assert.Equal(
            expected.DataConnectionStatuses.OrderBy(kv => kv.Key),
            actual.DataConnectionStatuses.OrderBy(kv => kv.Key));
        Assert.Equal(
            expected.TagResolutionCounts.OrderBy(kv => kv.Key),
            actual.TagResolutionCounts.OrderBy(kv => kv.Key));
        Assert.Equal(expected.ScriptErrorCount, actual.ScriptErrorCount);
        Assert.Equal(expected.AlarmEvaluationErrorCount, actual.AlarmEvaluationErrorCount);
        Assert.Equal(
            expected.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key),
            actual.StoreAndForwardBufferDepths.OrderBy(kv => kv.Key));
        Assert.Equal(expected.DeadLetterCount, actual.DeadLetterCount);
        Assert.Equal(expected.DeployedInstanceCount, actual.DeployedInstanceCount);
        Assert.Equal(expected.EnabledInstanceCount, actual.EnabledInstanceCount);
        Assert.Equal(expected.DisabledInstanceCount, actual.DisabledInstanceCount);
        Assert.Equal(expected.NodeRole, actual.NodeRole);
        Assert.Equal(expected.NodeHostname, actual.NodeHostname);
        Assert.Equal(
            expected.DataConnectionEndpoints?.OrderBy(kv => kv.Key),
            actual.DataConnectionEndpoints?.OrderBy(kv => kv.Key));
        Assert.Equal(
            expected.DataConnectionTagQuality?.OrderBy(kv => kv.Key),
            actual.DataConnectionTagQuality?.OrderBy(kv => kv.Key));
        Assert.Equal(expected.ParkedMessageCount, actual.ParkedMessageCount);
        Assert.Equal(expected.ClusterNodes, actual.ClusterNodes);
        Assert.Equal(expected.SiteAuditWriteFailures, actual.SiteAuditWriteFailures);
        Assert.Equal(expected.AuditRedactionFailure, actual.AuditRedactionFailure);
        Assert.Equal(expected.SiteAuditBacklog, actual.SiteAuditBacklog);
        Assert.Equal(expected.SiteEventLogWriteFailures, actual.SiteEventLogWriteFailures);
        Assert.Equal(expected.OldestParkedMessageAgeSeconds, actual.OldestParkedMessageAgeSeconds);
        Assert.Equal(expected.ScriptQueueDepth, actual.ScriptQueueDepth);
        Assert.Equal(expected.ScriptBusyThreads, actual.ScriptBusyThreads);
        Assert.Equal(expected.ScriptOldestBusyAgeSeconds, actual.ScriptOldestBusyAgeSeconds);
        Assert.Equal(expected.LocalDbReplicationConnected, actual.LocalDbReplicationConnected);
        Assert.Equal(expected.LocalDbOplogBacklog, actual.LocalDbOplogBacklog);
    }

    /// <summary>
    /// Serializes a wire message and parses it back, so every round-trip below crosses a
    /// real protobuf encode/decode rather than only exercising the mapper's object graph.
    /// This is what proves the three collection wrapper messages keep their presence
    /// when empty — an empty message encodes as a tag with zero-length payload, and a
    /// mapper that used a bare <c>repeated</c>/<c>map</c> field instead would decode an
    /// empty collection back as null with no test-visible difference at the object level.
    /// </summary>
    /// <typeparam name="T">Generated protobuf message type.</typeparam>
    /// <param name="message">The message to send through an encode/decode cycle.</param>
    /// <returns>An independent instance parsed from <paramref name="message"/>'s bytes.</returns>
    private static T Wire<T>(T message) where T : IMessage<T>, new() =>
        new MessageParser<T>(() => new T()).ParseFrom(message.ToByteArray());

    private static ZB.MOM.WW.Audit.AuditEvent NewAuditEvent(string? sourceScript = null) =>
        ScadaBridgeAuditEventFactory.Create(
            channel: AuditChannel.ApiOutbound,
            kind: AuditKind.ApiCallCached,
            status: AuditStatus.Forwarded,
            eventId: Guid.NewGuid(),
            occurredAtUtc: new DateTime(2026, 7, 22, 9, 0, 0, DateTimeKind.Utc),
            target: "ERP.GetOrder",
            sourceSiteId: "site-a",
            sourceNode: "node-a",
            sourceScript: sourceScript);

    private static SiteCall NewSiteCall() => new()
    {
        TrackedOperationId = TrackedOperationId.New(),
        Channel = "ApiOutbound",
        Target = "ERP.GetOrder",
        SourceSite = "site-a",
        SourceNode = "node-a",
        Status = "Delivered",
        RetryCount = 2,
        LastError = "transient 503",
        HttpStatus = 200,
        CreatedAtUtc = new DateTime(2026, 7, 22, 8, 0, 0, DateTimeKind.Utc),
        UpdatedAtUtc = new DateTime(2026, 7, 22, 8, 5, 0, DateTimeKind.Utc),
        TerminalAtUtc = new DateTime(2026, 7, 22, 8, 10, 0, DateTimeKind.Utc),
        IngestedAtUtc = new DateTime(2026, 7, 22, 8, 10, 1, DateTimeKind.Utc),
    };
}
