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.Enums;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;

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

/// <summary>
/// Canonical bridge between the seven in-process messages the site sends to central
/// and the wire format of <c>CentralControlService</c> (<c>Protos/central_control.proto</c>).
/// </summary>
/// <remarks>
/// <para>
/// The seven pairs mirror, one for one, the seven messages
/// <c>SiteCommunicationActor</c> forwards to <c>/user/central-communication</c> over
/// the control plane. The in-process and wire representations carry the SAME message types
/// end-to-end — central's handlers were untouched by the migration — so this mapper is
/// the only place the two representations meet, and a field that does not survive a
/// round-trip here is a field the gRPC transport silently drops.
/// </para>
/// <para>
/// <b>Ingest is deliberately absent from the message list.</b> <c>IngestAuditEvents</c>
/// and <c>IngestCachedTelemetry</c> reuse the <c>AuditEventBatch</c> /
/// <c>CachedTelemetryBatch</c> / <c>IngestAck</c> messages already defined for the
/// site-hosted <c>SiteStreamService</c>, so the per-row work is delegated to the
/// existing <see cref="AuditEventDtoMapper"/> and <see cref="SiteCallDtoMapper"/>; only
/// the batch/ack envelopes are assembled here.
/// </para>
///
/// <para><b>Conventions, applied uniformly across every method below.</b></para>
/// <list type="bullet">
///   <item>
///     <b>Nullable strings ↔ empty strings.</b> A proto3 scalar string cannot be absent,
///     so a null .NET string is written as <see cref="string.Empty"/> and an empty wire
///     string is read back as <see langword="null"/>. This is the convention already in
///     force on <see cref="AuditEventDtoMapper"/>, and it is why no field on this wire
///     may distinguish "null" from "deliberately empty".
///   </item>
///   <item>
///     <b>Nullable <see cref="Guid"/> ↔ string.</b> Execution ids travel as their "D"
///     string form; the empty string means <see langword="null"/>. A malformed non-empty
///     value throws out of <c>FromDto</c> rather than degrading to null — a corrupt
///     correlation id must not be laundered into "no correlation".
///   </item>
///   <item>
///     <b>Nullable numbers and booleans ↔ protobuf wrapper types.</b>
///     <c>Int32Value</c>/<c>Int64Value</c>/<c>DoubleValue</c>/<c>BoolValue</c> preserve
///     true null. Several health gauges (<c>LocalDbOplogBacklog</c>,
///     <c>LocalDbReplicationConnected</c>) are documented as "null means unknown, and
///     that is NOT the same as zero/false"; collapsing them to a bare scalar would
///     report a broken replication pair as healthy.
///   </item>
///   <item>
///     <b>Nullable collections ↔ wrapper messages.</b> proto3 cannot express presence on
///     a <c>repeated</c> or <c>map</c> field, so the three nullable
///     <see cref="SiteHealthReport"/> collections travel inside single-field wrapper
///     messages (<c>ConnectionEndpointMapDto</c>, <c>TagQualityMapDto</c>,
///     <c>NodeStatusListDto</c>). An absent wrapper is null; a present-but-empty wrapper
///     is an empty collection.
///   </item>
///   <item>
///     <b><see cref="DateTimeOffset"/> normalizes to a UTC instant.</b> A protobuf
///     <c>Timestamp</c> is an instant, not an offset-qualified local time, so the offset
///     component is dropped and the value round-trips with <c>Offset == TimeSpan.Zero</c>.
///     Every producer in this system stamps UTC (the repo-wide invariant; e.g.
///     <c>Notify.Send</c> uses <c>DateTimeOffset.UtcNow</c>), so this is lossless in
///     practice and the instant is preserved regardless.
///   </item>
/// </list>
/// </remarks>
public static class CentralControlDtoMapper
{
    // -----------------------------------------------------------------------
    // Notification Outbox (#21)
    // -----------------------------------------------------------------------

    /// <summary>Projects a <see cref="NotificationSubmit"/> onto its wire DTO.</summary>
    /// <param name="msg">The notification submission to project.</param>
    /// <returns>The wire-format DTO; null strings and null execution ids collapse to empty strings.</returns>
    public static NotificationSubmitDto ToDto(NotificationSubmit msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        return new NotificationSubmitDto
        {
            NotificationId = msg.NotificationId,
            ListName = msg.ListName,
            Subject = msg.Subject,
            Body = msg.Body,
            SourceSiteId = msg.SourceSiteId,
            SourceInstanceId = msg.SourceInstanceId ?? string.Empty,
            SourceScript = msg.SourceScript ?? string.Empty,
            SiteEnqueuedAt = Timestamp.FromDateTimeOffset(msg.SiteEnqueuedAt),
            OriginExecutionId = GuidToWire(msg.OriginExecutionId),
            OriginParentExecutionId = GuidToWire(msg.OriginParentExecutionId),
            SourceNode = msg.SourceNode ?? string.Empty,
        };
    }

    /// <summary>Reconstructs a <see cref="NotificationSubmit"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process message; empty strings rehydrate as null.</returns>
    public static NotificationSubmit FromDto(NotificationSubmitDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new NotificationSubmit(
            NotificationId: dto.NotificationId,
            ListName: dto.ListName,
            Subject: dto.Subject,
            Body: dto.Body,
            SourceSiteId: dto.SourceSiteId,
            SourceInstanceId: NullIfEmpty(dto.SourceInstanceId),
            SourceScript: NullIfEmpty(dto.SourceScript),
            SiteEnqueuedAt: dto.SiteEnqueuedAt.ToDateTimeOffset(),
            OriginExecutionId: GuidFromWire(dto.OriginExecutionId),
            OriginParentExecutionId: GuidFromWire(dto.OriginParentExecutionId),
            SourceNode: NullIfEmpty(dto.SourceNode));
    }

    /// <summary>Projects a <see cref="NotificationSubmitAck"/> onto its wire DTO.</summary>
    /// <param name="msg">The ack to project.</param>
    /// <returns>The wire-format DTO; a null error collapses to an empty string.</returns>
    public static NotificationSubmitAckDto ToDto(NotificationSubmitAck msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        return new NotificationSubmitAckDto
        {
            NotificationId = msg.NotificationId,
            Accepted = msg.Accepted,
            Error = msg.Error ?? string.Empty,
        };
    }

    /// <summary>Reconstructs a <see cref="NotificationSubmitAck"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process ack; an empty error rehydrates as null.</returns>
    public static NotificationSubmitAck FromDto(NotificationSubmitAckDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new NotificationSubmitAck(
            NotificationId: dto.NotificationId,
            Accepted: dto.Accepted,
            Error: NullIfEmpty(dto.Error));
    }

    /// <summary>Projects a <see cref="NotificationStatusQuery"/> onto its wire DTO.</summary>
    /// <param name="msg">The status query to project.</param>
    /// <returns>The wire-format DTO.</returns>
    public static NotificationStatusQueryDto ToDto(NotificationStatusQuery msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        return new NotificationStatusQueryDto
        {
            CorrelationId = msg.CorrelationId,
            NotificationId = msg.NotificationId,
        };
    }

    /// <summary>Reconstructs a <see cref="NotificationStatusQuery"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process query.</returns>
    public static NotificationStatusQuery FromDto(NotificationStatusQueryDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new NotificationStatusQuery(
            CorrelationId: dto.CorrelationId,
            NotificationId: dto.NotificationId);
    }

    /// <summary>Projects a <see cref="NotificationStatusResponse"/> onto its wire DTO.</summary>
    /// <param name="msg">The status response to project.</param>
    /// <returns>The wire-format DTO; a null delivery timestamp leaves the field unset.</returns>
    public static NotificationStatusResponseDto ToDto(NotificationStatusResponse msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        var dto = new NotificationStatusResponseDto
        {
            CorrelationId = msg.CorrelationId,
            Found = msg.Found,
            Status = msg.Status,
            RetryCount = msg.RetryCount,
            LastError = msg.LastError ?? string.Empty,
        };

        if (msg.DeliveredAt.HasValue)
        {
            dto.DeliveredAt = Timestamp.FromDateTimeOffset(msg.DeliveredAt.Value);
        }

        return dto;
    }

    /// <summary>Reconstructs a <see cref="NotificationStatusResponse"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process response; an unset delivery timestamp rehydrates as null.</returns>
    public static NotificationStatusResponse FromDto(NotificationStatusResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new NotificationStatusResponse(
            CorrelationId: dto.CorrelationId,
            Found: dto.Found,
            Status: dto.Status,
            RetryCount: dto.RetryCount,
            LastError: NullIfEmpty(dto.LastError),
            DeliveredAt: dto.DeliveredAt?.ToDateTimeOffset());
    }

    // -----------------------------------------------------------------------
    // Audit Log (#23) ingest — envelopes only; rows go through the existing mappers
    // -----------------------------------------------------------------------

    /// <summary>
    /// Projects an <see cref="IngestAuditEventsCommand"/> onto the shared
    /// <see cref="AuditEventBatch"/> wire message.
    /// </summary>
    /// <remarks>
    /// The per-row projection is <see cref="AuditEventDtoMapper.ToDto"/>, which is lossy
    /// by design: <c>ForwardState</c> is site-local storage state and <c>IngestedAtUtc</c>
    /// is stamped centrally at ingest, so neither travels.
    /// </remarks>
    /// <param name="cmd">The ingest command to project.</param>
    /// <returns>A batch carrying one DTO per audit event, in order.</returns>
    public static AuditEventBatch ToDto(IngestAuditEventsCommand cmd)
    {
        ArgumentNullException.ThrowIfNull(cmd);

        var batch = new AuditEventBatch();
        foreach (var evt in cmd.Events)
        {
            batch.Events.Add(AuditEventDtoMapper.ToDto(evt));
        }

        return batch;
    }

    /// <summary>
    /// Reconstructs an <see cref="IngestAuditEventsCommand"/> from the shared
    /// <see cref="AuditEventBatch"/> wire message — the shape central's
    /// <c>CentralCommunicationActor</c> already handles.
    /// </summary>
    /// <param name="batch">The wire batch to reconstruct.</param>
    /// <returns>The in-process ingest command.</returns>
    public static IngestAuditEventsCommand FromDto(AuditEventBatch batch)
    {
        ArgumentNullException.ThrowIfNull(batch);

        var events = new List<ZB.MOM.WW.Audit.AuditEvent>(batch.Events.Count);
        foreach (var dto in batch.Events)
        {
            events.Add(AuditEventDtoMapper.FromDto(dto));
        }

        return new IngestAuditEventsCommand(events);
    }

    /// <summary>
    /// Projects an <see cref="IngestCachedTelemetryCommand"/> onto the shared
    /// <see cref="CachedTelemetryBatch"/> wire message.
    /// </summary>
    /// <param name="cmd">The cached-telemetry ingest command to project.</param>
    /// <returns>A batch carrying one packet (audit row + operational row) per entry, in order.</returns>
    public static CachedTelemetryBatch ToDto(IngestCachedTelemetryCommand cmd)
    {
        ArgumentNullException.ThrowIfNull(cmd);

        var batch = new CachedTelemetryBatch();
        foreach (var entry in cmd.Entries)
        {
            batch.Packets.Add(new CachedTelemetryPacket
            {
                AuditEvent = AuditEventDtoMapper.ToDto(entry.Audit),
                Operational = SiteCallDtoMapper.ToDto(entry.SiteCall),
            });
        }

        return batch;
    }

    /// <summary>
    /// Reconstructs an <see cref="IngestCachedTelemetryCommand"/> from the shared
    /// <see cref="CachedTelemetryBatch"/> wire message.
    /// </summary>
    /// <param name="batch">The wire batch to reconstruct.</param>
    /// <returns>The in-process dual-write ingest command.</returns>
    public static IngestCachedTelemetryCommand FromDto(CachedTelemetryBatch batch)
    {
        ArgumentNullException.ThrowIfNull(batch);

        var entries = new List<CachedTelemetryEntry>(batch.Packets.Count);
        foreach (var packet in batch.Packets)
        {
            entries.Add(new CachedTelemetryEntry(
                AuditEventDtoMapper.FromDto(packet.AuditEvent),
                SiteCallDtoMapper.FromDto(packet.Operational)));
        }

        return new IngestCachedTelemetryCommand(entries);
    }

    /// <summary>
    /// Projects the accepted-id list of an ingest reply onto the shared
    /// <see cref="IngestAck"/> wire message. Shared by both ingest RPCs — the two
    /// central reply types differ only in which handler produced them.
    /// </summary>
    /// <param name="acceptedEventIds">Ids central considers durably persisted.</param>
    /// <returns>The wire ack carrying the ids in "D" string form, in order.</returns>
    public static IngestAck ToIngestAck(IReadOnlyList<Guid> acceptedEventIds)
    {
        ArgumentNullException.ThrowIfNull(acceptedEventIds);

        var ack = new IngestAck();
        foreach (var id in acceptedEventIds)
        {
            ack.AcceptedEventIds.Add(id.ToString());
        }

        return ack;
    }

    /// <summary>
    /// Reads the accepted-id list back out of an <see cref="IngestAck"/>.
    /// </summary>
    /// <param name="ack">The wire ack to read.</param>
    /// <returns>The accepted event ids, in wire order.</returns>
    public static IReadOnlyList<Guid> FromIngestAck(IngestAck ack)
    {
        ArgumentNullException.ThrowIfNull(ack);

        var ids = new List<Guid>(ack.AcceptedEventIds.Count);
        foreach (var id in ack.AcceptedEventIds)
        {
            ids.Add(Guid.Parse(id));
        }

        return ids;
    }

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

    /// <summary>Projects a <see cref="ReconcileSiteRequest"/> onto its wire DTO.</summary>
    /// <param name="msg">The reconcile request to project.</param>
    /// <returns>The wire-format DTO carrying the node's local name→revision-hash inventory.</returns>
    public static ReconcileSiteRequestDto ToDto(ReconcileSiteRequest msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        var dto = new ReconcileSiteRequestDto
        {
            SiteIdentifier = msg.SiteIdentifier,
            NodeId = msg.NodeId,
        };

        foreach (var (name, hash) in msg.LocalNameToRevisionHash)
        {
            dto.LocalNameToRevisionHash[name] = hash;
        }

        return dto;
    }

    /// <summary>Reconstructs a <see cref="ReconcileSiteRequest"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process reconcile request.</returns>
    public static ReconcileSiteRequest FromDto(ReconcileSiteRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ReconcileSiteRequest(
            SiteIdentifier: dto.SiteIdentifier,
            NodeId: dto.NodeId,
            LocalNameToRevisionHash: new Dictionary<string, string>(dto.LocalNameToRevisionHash));
    }

    /// <summary>Projects a <see cref="ReconcileSiteResponse"/> onto its wire DTO.</summary>
    /// <param name="msg">The reconcile response to project.</param>
    /// <returns>The wire-format DTO carrying the gap items, orphan names and fetch base URL.</returns>
    public static ReconcileSiteResponseDto ToDto(ReconcileSiteResponse msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        var dto = new ReconcileSiteResponseDto
        {
            CentralFetchBaseUrl = msg.CentralFetchBaseUrl,
        };

        foreach (var item in msg.Gap)
        {
            dto.Gap.Add(new ReconcileGapItemDto
            {
                InstanceUniqueName = item.InstanceUniqueName,
                DeploymentId = item.DeploymentId,
                RevisionHash = item.RevisionHash,
                IsEnabled = item.IsEnabled,
                FetchToken = item.FetchToken,
            });
        }

        dto.OrphanNames.AddRange(msg.OrphanNames);

        return dto;
    }

    /// <summary>Reconstructs a <see cref="ReconcileSiteResponse"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process reconcile response.</returns>
    public static ReconcileSiteResponse FromDto(ReconcileSiteResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        var gap = new List<ReconcileGapItem>(dto.Gap.Count);
        foreach (var item in dto.Gap)
        {
            gap.Add(new ReconcileGapItem(
                InstanceUniqueName: item.InstanceUniqueName,
                DeploymentId: item.DeploymentId,
                RevisionHash: item.RevisionHash,
                IsEnabled: item.IsEnabled,
                FetchToken: item.FetchToken));
        }

        return new ReconcileSiteResponse(
            Gap: gap,
            OrphanNames: dto.OrphanNames.ToList(),
            CentralFetchBaseUrl: dto.CentralFetchBaseUrl);
    }

    // -----------------------------------------------------------------------
    // Health Monitoring (#11)
    // -----------------------------------------------------------------------

    /// <summary>Projects a <see cref="SiteHealthReport"/> onto its wire DTO.</summary>
    /// <remarks>
    /// The three nullable collections travel inside wrapper messages so a null stays
    /// distinguishable from an empty collection; the nullable gauges travel in protobuf
    /// wrapper types for the same reason.
    /// </remarks>
    /// <param name="msg">The health report to project.</param>
    /// <returns>The wire-format DTO.</returns>
    public static SiteHealthReportDto ToDto(SiteHealthReport msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        var dto = new SiteHealthReportDto
        {
            SiteId = msg.SiteId,
            SequenceNumber = msg.SequenceNumber,
            ReportTimestamp = Timestamp.FromDateTimeOffset(msg.ReportTimestamp),
            ScriptErrorCount = msg.ScriptErrorCount,
            AlarmEvaluationErrorCount = msg.AlarmEvaluationErrorCount,
            DeadLetterCount = msg.DeadLetterCount,
            DeployedInstanceCount = msg.DeployedInstanceCount,
            EnabledInstanceCount = msg.EnabledInstanceCount,
            DisabledInstanceCount = msg.DisabledInstanceCount,
            NodeRole = msg.NodeRole,
            NodeHostname = msg.NodeHostname,
            ParkedMessageCount = msg.ParkedMessageCount,
            SiteAuditWriteFailures = msg.SiteAuditWriteFailures,
            AuditRedactionFailure = msg.AuditRedactionFailure,
            SiteEventLogWriteFailures = msg.SiteEventLogWriteFailures,
            OldestParkedMessageAgeSeconds = msg.OldestParkedMessageAgeSeconds,
            ScriptQueueDepth = msg.ScriptQueueDepth,
            ScriptBusyThreads = msg.ScriptBusyThreads,
            ScriptOldestBusyAgeSeconds = msg.ScriptOldestBusyAgeSeconds,
            LocalDbReplicationConnected = msg.LocalDbReplicationConnected,
            LocalDbOplogBacklog = msg.LocalDbOplogBacklog,
        };

        foreach (var (name, health) in msg.DataConnectionStatuses)
        {
            dto.DataConnectionStatuses[name] = ToDto(health);
        }

        foreach (var (name, resolution) in msg.TagResolutionCounts)
        {
            dto.TagResolutionCounts[name] = new TagResolutionStatusDto
            {
                TotalSubscribed = resolution.TotalSubscribed,
                SuccessfullyResolved = resolution.SuccessfullyResolved,
            };
        }

        foreach (var (name, depth) in msg.StoreAndForwardBufferDepths)
        {
            dto.StoreAndForwardBufferDepths[name] = depth;
        }

        if (msg.DataConnectionEndpoints is { } endpoints)
        {
            var wrapper = new ConnectionEndpointMapDto();
            foreach (var (name, endpoint) in endpoints)
            {
                wrapper.Entries[name] = endpoint;
            }

            dto.DataConnectionEndpoints = wrapper;
        }

        if (msg.DataConnectionTagQuality is { } tagQuality)
        {
            var wrapper = new TagQualityMapDto();
            foreach (var (name, counts) in tagQuality)
            {
                wrapper.Entries[name] = new TagQualityCountsDto
                {
                    Good = counts.Good,
                    Bad = counts.Bad,
                    Uncertain = counts.Uncertain,
                };
            }

            dto.DataConnectionTagQuality = wrapper;
        }

        if (msg.ClusterNodes is { } clusterNodes)
        {
            var wrapper = new NodeStatusListDto();
            foreach (var node in clusterNodes)
            {
                wrapper.Nodes.Add(new NodeStatusDto
                {
                    Hostname = node.Hostname,
                    IsOnline = node.IsOnline,
                    Role = node.Role,
                });
            }

            dto.ClusterNodes = wrapper;
        }

        if (msg.SiteAuditBacklog is { } backlog)
        {
            var snapshot = new SiteAuditBacklogSnapshotDto
            {
                PendingCount = backlog.PendingCount,
                OnDiskBytes = backlog.OnDiskBytes,
            };

            if (backlog.OldestPendingUtc.HasValue)
            {
                snapshot.OldestPendingUtc = Timestamp.FromDateTime(EnsureUtc(backlog.OldestPendingUtc.Value));
            }

            dto.SiteAuditBacklog = snapshot;
        }

        return dto;
    }

    /// <summary>Reconstructs a <see cref="SiteHealthReport"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process health report; absent wrappers rehydrate as null, not as empty.</returns>
    public static SiteHealthReport FromDto(SiteHealthReportDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        var statuses = new Dictionary<string, ConnectionHealth>(dto.DataConnectionStatuses.Count);
        foreach (var (name, health) in dto.DataConnectionStatuses)
        {
            statuses[name] = FromDto(health);
        }

        var resolution = new Dictionary<string, TagResolutionStatus>(dto.TagResolutionCounts.Count);
        foreach (var (name, counts) in dto.TagResolutionCounts)
        {
            resolution[name] = new TagResolutionStatus(counts.TotalSubscribed, counts.SuccessfullyResolved);
        }

        var bufferDepths = new Dictionary<string, int>(dto.StoreAndForwardBufferDepths);

        Dictionary<string, string>? endpoints = null;
        if (dto.DataConnectionEndpoints is { } endpointWrapper)
        {
            endpoints = new Dictionary<string, string>(endpointWrapper.Entries);
        }

        Dictionary<string, TagQualityCounts>? tagQuality = null;
        if (dto.DataConnectionTagQuality is { } tagQualityWrapper)
        {
            tagQuality = new Dictionary<string, TagQualityCounts>(tagQualityWrapper.Entries.Count);
            foreach (var (name, counts) in tagQualityWrapper.Entries)
            {
                tagQuality[name] = new TagQualityCounts(counts.Good, counts.Bad, counts.Uncertain);
            }
        }

        List<NodeStatus>? clusterNodes = null;
        if (dto.ClusterNodes is { } nodeWrapper)
        {
            clusterNodes = new List<NodeStatus>(nodeWrapper.Nodes.Count);
            foreach (var node in nodeWrapper.Nodes)
            {
                clusterNodes.Add(new NodeStatus(node.Hostname, node.IsOnline, node.Role));
            }
        }

        SiteAuditBacklogSnapshot? backlog = null;
        if (dto.SiteAuditBacklog is { } snapshot)
        {
            backlog = new SiteAuditBacklogSnapshot(
                PendingCount: snapshot.PendingCount,
                OldestPendingUtc: snapshot.OldestPendingUtc is null
                    ? null
                    : DateTime.SpecifyKind(snapshot.OldestPendingUtc.ToDateTime(), DateTimeKind.Utc),
                OnDiskBytes: snapshot.OnDiskBytes);
        }

        return new SiteHealthReport(
            SiteId: dto.SiteId,
            SequenceNumber: dto.SequenceNumber,
            ReportTimestamp: dto.ReportTimestamp.ToDateTimeOffset(),
            DataConnectionStatuses: statuses,
            TagResolutionCounts: resolution,
            ScriptErrorCount: dto.ScriptErrorCount,
            AlarmEvaluationErrorCount: dto.AlarmEvaluationErrorCount,
            StoreAndForwardBufferDepths: bufferDepths,
            DeadLetterCount: dto.DeadLetterCount,
            DeployedInstanceCount: dto.DeployedInstanceCount,
            EnabledInstanceCount: dto.EnabledInstanceCount,
            DisabledInstanceCount: dto.DisabledInstanceCount,
            NodeRole: dto.NodeRole,
            NodeHostname: dto.NodeHostname,
            DataConnectionEndpoints: endpoints,
            DataConnectionTagQuality: tagQuality,
            ParkedMessageCount: dto.ParkedMessageCount,
            ClusterNodes: clusterNodes,
            SiteAuditWriteFailures: dto.SiteAuditWriteFailures,
            AuditRedactionFailure: dto.AuditRedactionFailure,
            SiteAuditBacklog: backlog,
            SiteEventLogWriteFailures: dto.SiteEventLogWriteFailures,
            OldestParkedMessageAgeSeconds: dto.OldestParkedMessageAgeSeconds)
        {
            // Init-only members: SiteHealthReport surfaces the scheduler and LocalDb
            // gauges as init properties rather than positional parameters, so they
            // cannot be passed to the constructor above.
            ScriptQueueDepth = dto.ScriptQueueDepth,
            ScriptBusyThreads = dto.ScriptBusyThreads,
            ScriptOldestBusyAgeSeconds = dto.ScriptOldestBusyAgeSeconds,
            LocalDbReplicationConnected = dto.LocalDbReplicationConnected,
            LocalDbOplogBacklog = dto.LocalDbOplogBacklog,
        };
    }

    /// <summary>Projects a <see cref="SiteHealthReportAck"/> onto its wire DTO.</summary>
    /// <param name="msg">The ack to project.</param>
    /// <returns>The wire-format DTO; a null error collapses to an empty string.</returns>
    public static SiteHealthReportAckDto ToDto(SiteHealthReportAck msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        return new SiteHealthReportAckDto
        {
            SiteId = msg.SiteId,
            SequenceNumber = msg.SequenceNumber,
            Accepted = msg.Accepted,
            Error = msg.Error ?? string.Empty,
        };
    }

    /// <summary>Reconstructs a <see cref="SiteHealthReportAck"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process ack; an empty error rehydrates as null.</returns>
    public static SiteHealthReportAck FromDto(SiteHealthReportAckDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SiteHealthReportAck(
            SiteId: dto.SiteId,
            SequenceNumber: dto.SequenceNumber,
            Accepted: dto.Accepted,
            Error: NullIfEmpty(dto.Error));
    }

    /// <summary>Projects a <see cref="HeartbeatMessage"/> onto its wire DTO.</summary>
    /// <param name="msg">The heartbeat to project.</param>
    /// <returns>The wire-format DTO.</returns>
    public static HeartbeatDto ToDto(HeartbeatMessage msg)
    {
        ArgumentNullException.ThrowIfNull(msg);

        return new HeartbeatDto
        {
            SiteId = msg.SiteId,
            NodeHostname = msg.NodeHostname,
            IsActive = msg.IsActive,
            Timestamp = Timestamp.FromDateTimeOffset(msg.Timestamp),
            Synthetic = msg.Synthetic,
        };
    }

    /// <summary>Reconstructs a <see cref="HeartbeatMessage"/> from its wire DTO.</summary>
    /// <param name="dto">The wire-format DTO to reconstruct.</param>
    /// <returns>The in-process heartbeat.</returns>
    public static HeartbeatMessage FromDto(HeartbeatDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new HeartbeatMessage(
            SiteId: dto.SiteId,
            NodeHostname: dto.NodeHostname,
            IsActive: dto.IsActive,
            Timestamp: dto.Timestamp.ToDateTimeOffset(),
            Synthetic: dto.Synthetic);
    }

    /// <summary>Projects a <see cref="ConnectionHealth"/> onto its wire enum.</summary>
    /// <param name="health">The connection health to project.</param>
    /// <returns>The wire enum value. Never <c>ConnectionHealthUnspecified</c>.</returns>
    /// <exception cref="ArgumentOutOfRangeException">
    /// A <see cref="ConnectionHealth"/> value was added without extending this mapping.
    /// Throwing beats a silent default: an unmapped state would otherwise be reported as
    /// whichever value happened to be first.
    /// </exception>
    public static ConnectionHealthEnum ToDto(ConnectionHealth health) => health switch
    {
        ConnectionHealth.Connected => ConnectionHealthEnum.ConnectionHealthConnected,
        ConnectionHealth.Disconnected => ConnectionHealthEnum.ConnectionHealthDisconnected,
        ConnectionHealth.Connecting => ConnectionHealthEnum.ConnectionHealthConnecting,
        ConnectionHealth.Error => ConnectionHealthEnum.ConnectionHealthError,
        _ => throw new ArgumentOutOfRangeException(nameof(health), health, "Unmapped ConnectionHealth value"),
    };

    /// <summary>Reconstructs a <see cref="ConnectionHealth"/> from its wire enum.</summary>
    /// <remarks>
    /// An unspecified or unknown wire value decodes to <see cref="ConnectionHealth.Error"/>,
    /// never to <see cref="ConnectionHealth.Connected"/>. A newer site sending a value this
    /// build has never heard of must not have it rendered as "healthy" on the central
    /// health page — the safe direction for an unknown connection state is "not working".
    /// </remarks>
    /// <param name="health">The wire enum value to reconstruct.</param>
    /// <returns>The in-process connection health.</returns>
    public static ConnectionHealth FromDto(ConnectionHealthEnum health) => health switch
    {
        ConnectionHealthEnum.ConnectionHealthConnected => ConnectionHealth.Connected,
        ConnectionHealthEnum.ConnectionHealthDisconnected => ConnectionHealth.Disconnected,
        ConnectionHealthEnum.ConnectionHealthConnecting => ConnectionHealth.Connecting,
        _ => ConnectionHealth.Error,
    };

    private static string GuidToWire(Guid? value) =>
        value?.ToString() ?? string.Empty;

    private static Guid? GuidFromWire(string? value) =>
        string.IsNullOrEmpty(value) ? null : Guid.Parse(value);

    private static string? NullIfEmpty(string? value) =>
        string.IsNullOrEmpty(value) ? null : value;

    // All ScadaBridge timestamps are UTC by invariant; Timestamp.FromDateTime requires
    // UTC kind. Specify (never convert) so a value read back from SQLite with Kind=Utc
    // passes through and a defensively-unspecified one is treated as the UTC it already
    // is. Mirrors AuditEventDtoMapper/SiteCallDtoMapper.EnsureUtc.
    private static DateTime EnsureUtc(DateTime value) =>
        value.Kind == DateTimeKind.Utc ? value : DateTime.SpecifyKind(value, DateTimeKind.Utc);
}
