using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Artifacts;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DataConnection;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Deployment;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Lifecycle;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.RemoteQuery;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using BoolValue = Google.Protobuf.WellKnownTypes.BoolValue;
using Duration = Google.Protobuf.WellKnownTypes.Duration;
using Int32Value = Google.Protobuf.WellKnownTypes.Int32Value;
using StringValue = Google.Protobuf.WellKnownTypes.StringValue;
using Timestamp = Google.Protobuf.WellKnownTypes.Timestamp;

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

/// <summary>
/// Canonical bridge between the in-process central→site command/reply records
/// (Commons <c>Messages.*</c>) and the wire messages of
/// <c>Protos/site_command.proto</c>. Every one of the 28 migrated commands and
/// all 22 reply shapes pass through here; nothing else may hand-roll the
/// translation, so the contract has exactly one definition.
/// </summary>
/// <remarks>
/// <para>
/// Mirrors the sibling <see cref="AuditEventDtoMapper"/>/<see cref="SiteCallDtoMapper"/>
/// and lives in <c>Communication</c> for the same reason: this project owns the
/// generated wire types and already references <c>Commons</c>, so both the site
/// server (T1B.2) and the central transport (T1B.3) can share one implementation
/// without a project-reference cycle.
/// </para>
/// <para><b>Null conventions.</b> proto3 has no presence for scalars, so:</para>
/// <list type="bullet">
///   <item>
///     Nullable <b>strings</b> ride as plain <c>string</c> and the empty string
///     represents <c>null</c> — the convention <see cref="AuditEventDtoMapper"/>
///     already established. It is a normalisation, not a loss: every such member
///     here is an optional error message, token, filter or id where "" and
///     <c>null</c> carry the same meaning. The one exception is
///     <see cref="RouteToWaitForAttributeRequest.TargetValueEncoded"/>, where
///     "wait for the empty string" is a real target distinct from "no target";
///     that field uses a <c>StringValue</c> wrapper instead.
///   </item>
///   <item>
///     Nullable <b>ints/bools</b> use the <c>Int32Value</c>/<c>BoolValue</c>
///     wrappers, and nullable <b>enums</b> use a one-field message of the same
///     shape (proto3 enums have no presence and no stock wrapper exists).
///   </item>
///   <item>
///     Nullable <b>collections</b> are wrapped in a per-collection message, so
///     the null/empty distinction becomes a message-presence question. proto3
///     <c>repeated</c>/<c>map</c> alone collapses the two — precisely the silent
///     data-loss class the transport round-trip guard caught in PLAN-05 T8.
///   </item>
///   <item>
///     <c>Guid?</c> members ride as a <c>string</c> holding the "D" form, with
///     the empty string representing <c>null</c>.
///     <see cref="TrackedOperationId"/> (a non-nullable struct over a
///     <see cref="Guid"/>) rides as the same "D" string.
///   </item>
/// </list>
/// <para>
/// <b>Time.</b> DTO members declared <see cref="DateTimeOffset"/> use
/// <c>google.protobuf.Timestamp</c>. That normalises to UTC, which is lossless
/// under <see cref="DateTimeOffset"/> equality (it compares instants), and every
/// timestamp in this contract is documented UTC anyway. Members declared
/// <see cref="DateTime"/> (the certificate validity bounds) are coerced to
/// <see cref="DateTimeKind.Utc"/> first, matching their <c>…Utc</c> names.
/// Loosely-typed <c>object?</c> values are the exception and keep their exact
/// kind/offset — see <see cref="LooseValueCodec"/>.
/// </para>
/// <para>
/// <b>Enums</b> translate through explicit switches in both directions, never by
/// ordinal, so reordering a C# enum cannot silently re-map the wire. Every wire
/// enum reserves 0 for <c>_UNSPECIFIED</c>; decoding an unspecified or unknown
/// value yields the documented safe default for that enum (never a throw — a
/// version-skewed peer must not be able to fault a whole command).
/// </para>
/// </remarks>
public static class SiteCommandDtoMapper
{
    // ─────────────────────────────────────────────────────────────────────
    // Group classification — the authoritative 28-command inventory in code.
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Classifies a central→site command onto the RPC group that carries it.
    /// The central transport (T1B.3) uses this to pick both the RPC and the
    /// deadline; the coverage tests use it as the command inventory.
    /// </summary>
    /// <param name="command">The command record to classify.</param>
    /// <returns>The <see cref="SiteCommandGroup"/> whose RPC carries the command.</returns>
    /// <exception cref="ArgumentException">The type is not part of the migrated command set.</exception>
    public static SiteCommandGroup GroupOf(object command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return command switch
        {
            RefreshDeploymentCommand or EnableInstanceCommand or DisableInstanceCommand
                or DeleteInstanceCommand or DeploymentStateQueryRequest or DeployArtifactsCommand
                => SiteCommandGroup.Lifecycle,

            BrowseNodeCommand or SearchAddressSpaceCommand or ReadTagValuesCommand
                or VerifyEndpointCommand or TrustServerCertCommand or ListServerCertsCommand
                or RemoveServerCertCommand or WriteTagRequest
                => SiteCommandGroup.OpcUa,

            EventLogQueryRequest or DebugSnapshotRequest or SubscribeDebugViewRequest
                or UnsubscribeDebugViewRequest
                => SiteCommandGroup.Query,

            ParkedMessageQueryRequest or ParkedMessageRetryRequest or ParkedMessageDiscardRequest
                or RetryParkedOperation or DiscardParkedOperation
                => SiteCommandGroup.Parked,

            RouteToCallRequest or RouteToGetAttributesRequest or RouteToSetAttributesRequest
                or RouteToWaitForAttributeRequest
                => SiteCommandGroup.Route,

            TriggerSiteFailover => SiteCommandGroup.Failover,

            _ => throw new ArgumentException(
                $"'{command.GetType().Name}' is not a migrated site command.", nameof(command))
        };
    }

    /// <summary>
    /// Classifies a site→central reply onto the RPC group whose reply envelope
    /// carries it — the mirror of <see cref="GroupOf"/>, used by the site
    /// dispatcher to pack whatever a handler answered into the right envelope.
    /// </summary>
    /// <param name="reply">The reply record to classify.</param>
    /// <returns>The <see cref="SiteCommandGroup"/> whose reply envelope carries it.</returns>
    /// <exception cref="ArgumentException">The type is not a migrated reply shape.</exception>
    public static SiteCommandGroup GroupOfReply(object reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply switch
        {
            DeploymentStatusResponse or InstanceLifecycleResponse or DeploymentStateQueryResponse
                or ArtifactDeploymentResponse
                => SiteCommandGroup.Lifecycle,

            BrowseNodeResult or SearchAddressSpaceResult or ReadTagValuesResult or VerifyEndpointResult
                or CertTrustResult or WriteTagResponse
                => SiteCommandGroup.OpcUa,

            EventLogQueryResponse or DebugViewSnapshot or UnsubscribeDebugViewAck
                => SiteCommandGroup.Query,

            ParkedMessageQueryResponse or ParkedMessageRetryResponse or ParkedMessageDiscardResponse
                or ParkedOperationActionAck
                => SiteCommandGroup.Parked,

            RouteToCallResponse or RouteToGetAttributesResponse or RouteToSetAttributesResponse
                or RouteToWaitForAttributeResponse
                => SiteCommandGroup.Route,

            SiteFailoverAck => SiteCommandGroup.Failover,

            _ => throw new ArgumentException(
                $"'{reply.GetType().Name}' is not a migrated site reply.", nameof(reply))
        };
    }

    // ─────────────────────────────────────────────────────────────────────
    // RPC 1 — ExecuteLifecycle: envelopes
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Packs a lifecycle-group command into its request envelope.</summary>
    /// <param name="command">One of the six lifecycle-group commands.</param>
    /// <returns>The populated request envelope.</returns>
    /// <exception cref="ArgumentException">The command does not belong to this group.</exception>
    public static LifecycleRequest ToLifecycleRequest(object command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return command switch
        {
            RefreshDeploymentCommand c => new LifecycleRequest { RefreshDeployment = ToProto(c) },
            EnableInstanceCommand c => new LifecycleRequest { EnableInstance = ToProto(c) },
            DisableInstanceCommand c => new LifecycleRequest { DisableInstance = ToProto(c) },
            DeleteInstanceCommand c => new LifecycleRequest { DeleteInstance = ToProto(c) },
            DeploymentStateQueryRequest c => new LifecycleRequest { DeploymentStateQuery = ToProto(c) },
            DeployArtifactsCommand c => new LifecycleRequest { DeployArtifacts = ToProto(c) },
            _ => throw NotInGroup(command, SiteCommandGroup.Lifecycle)
        };
    }

    /// <summary>Unpacks a lifecycle-group request envelope back to its command record.</summary>
    /// <param name="request">The request envelope received on the wire.</param>
    /// <returns>The decoded command record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromLifecycleRequest(LifecycleRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return request.CommandCase switch
        {
            LifecycleRequest.CommandOneofCase.RefreshDeployment => FromProto(request.RefreshDeployment),
            LifecycleRequest.CommandOneofCase.EnableInstance => FromProto(request.EnableInstance),
            LifecycleRequest.CommandOneofCase.DisableInstance => FromProto(request.DisableInstance),
            LifecycleRequest.CommandOneofCase.DeleteInstance => FromProto(request.DeleteInstance),
            LifecycleRequest.CommandOneofCase.DeploymentStateQuery => FromProto(request.DeploymentStateQuery),
            LifecycleRequest.CommandOneofCase.DeployArtifacts => FromProto(request.DeployArtifacts),
            _ => throw UnknownCase(request.CommandCase, nameof(LifecycleRequest))
        };
    }

    /// <summary>Packs a lifecycle-group reply into its reply envelope.</summary>
    /// <param name="reply">One of the four lifecycle-group reply records.</param>
    /// <returns>The populated reply envelope.</returns>
    /// <exception cref="ArgumentException">The reply does not belong to this group.</exception>
    public static LifecycleReply ToLifecycleReply(object reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply switch
        {
            DeploymentStatusResponse r => new LifecycleReply { DeploymentStatus = ToProto(r) },
            InstanceLifecycleResponse r => new LifecycleReply { InstanceLifecycle = ToProto(r) },
            DeploymentStateQueryResponse r => new LifecycleReply { DeploymentStateQuery = ToProto(r) },
            ArtifactDeploymentResponse r => new LifecycleReply { ArtifactDeployment = ToProto(r) },
            _ => throw NotInGroup(reply, SiteCommandGroup.Lifecycle)
        };
    }

    /// <summary>Unpacks a lifecycle-group reply envelope back to its reply record.</summary>
    /// <param name="reply">The reply envelope received on the wire.</param>
    /// <returns>The decoded reply record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromLifecycleReply(LifecycleReply reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply.ReplyCase switch
        {
            LifecycleReply.ReplyOneofCase.DeploymentStatus => FromProto(reply.DeploymentStatus),
            LifecycleReply.ReplyOneofCase.InstanceLifecycle => FromProto(reply.InstanceLifecycle),
            LifecycleReply.ReplyOneofCase.DeploymentStateQuery => FromProto(reply.DeploymentStateQuery),
            LifecycleReply.ReplyOneofCase.ArtifactDeployment => FromProto(reply.ArtifactDeployment),
            _ => throw UnknownCase(reply.ReplyCase, nameof(LifecycleReply))
        };
    }

    // ── Lifecycle commands ──

    /// <summary>Projects a <see cref="RefreshDeploymentCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static RefreshDeploymentCommandDto ToProto(RefreshDeploymentCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new RefreshDeploymentCommandDto
        {
            DeploymentId = command.DeploymentId,
            InstanceUniqueName = command.InstanceUniqueName,
            RevisionHash = command.RevisionHash,
            DeployedBy = command.DeployedBy,
            Timestamp = Ts(command.Timestamp),
            CentralFetchBaseUrl = command.CentralFetchBaseUrl,
            FetchToken = command.FetchToken
        };
    }

    /// <summary>Rehydrates a <see cref="RefreshDeploymentCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static RefreshDeploymentCommand FromProto(RefreshDeploymentCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RefreshDeploymentCommand(
            dto.DeploymentId,
            dto.InstanceUniqueName,
            dto.RevisionHash,
            dto.DeployedBy,
            Dto(dto.Timestamp),
            dto.CentralFetchBaseUrl,
            dto.FetchToken);
    }

    /// <summary>Projects an <see cref="EnableInstanceCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static EnableInstanceCommandDto ToProto(EnableInstanceCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new EnableInstanceCommandDto
        {
            CommandId = command.CommandId,
            InstanceUniqueName = command.InstanceUniqueName,
            Timestamp = Ts(command.Timestamp)
        };
    }

    /// <summary>Rehydrates an <see cref="EnableInstanceCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static EnableInstanceCommand FromProto(EnableInstanceCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new EnableInstanceCommand(dto.CommandId, dto.InstanceUniqueName, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="DisableInstanceCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static DisableInstanceCommandDto ToProto(DisableInstanceCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new DisableInstanceCommandDto
        {
            CommandId = command.CommandId,
            InstanceUniqueName = command.InstanceUniqueName,
            Timestamp = Ts(command.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="DisableInstanceCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static DisableInstanceCommand FromProto(DisableInstanceCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DisableInstanceCommand(dto.CommandId, dto.InstanceUniqueName, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="DeleteInstanceCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static DeleteInstanceCommandDto ToProto(DeleteInstanceCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new DeleteInstanceCommandDto
        {
            CommandId = command.CommandId,
            InstanceUniqueName = command.InstanceUniqueName,
            Timestamp = Ts(command.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="DeleteInstanceCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static DeleteInstanceCommand FromProto(DeleteInstanceCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DeleteInstanceCommand(dto.CommandId, dto.InstanceUniqueName, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="DeploymentStateQueryRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static DeploymentStateQueryRequestDto ToProto(DeploymentStateQueryRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new DeploymentStateQueryRequestDto
        {
            CorrelationId = request.CorrelationId,
            InstanceUniqueName = request.InstanceUniqueName,
            Timestamp = Ts(request.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="DeploymentStateQueryRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static DeploymentStateQueryRequest FromProto(DeploymentStateQueryRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DeploymentStateQueryRequest(dto.CorrelationId, dto.InstanceUniqueName, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="DeployArtifactsCommand"/> onto the wire.</summary>
    /// <remarks>Each artifact collection is nullable; a null collection leaves its wrapper message unset.</remarks>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static DeployArtifactsCommandDto ToProto(DeployArtifactsCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        var dto = new DeployArtifactsCommandDto
        {
            DeploymentId = command.DeploymentId,
            Timestamp = Ts(command.Timestamp)
        };

        if (command.SharedScripts is not null)
        {
            var list = new SharedScriptArtifactListDto();
            list.Items.AddRange(command.SharedScripts.Select(ToProto));
            dto.SharedScripts = list;
        }

        if (command.ExternalSystems is not null)
        {
            var list = new ExternalSystemArtifactListDto();
            list.Items.AddRange(command.ExternalSystems.Select(ToProto));
            dto.ExternalSystems = list;
        }

        if (command.DatabaseConnections is not null)
        {
            var list = new DatabaseConnectionArtifactListDto();
            list.Items.AddRange(command.DatabaseConnections.Select(ToProto));
            dto.DatabaseConnections = list;
        }

        if (command.NotificationLists is not null)
        {
            var list = new NotificationListArtifactListDto();
            list.Items.AddRange(command.NotificationLists.Select(ToProto));
            dto.NotificationLists = list;
        }

        if (command.DataConnections is not null)
        {
            var list = new DataConnectionArtifactListDto();
            list.Items.AddRange(command.DataConnections.Select(ToProto));
            dto.DataConnections = list;
        }

        if (command.SmtpConfigurations is not null)
        {
            var list = new SmtpConfigurationArtifactListDto();
            list.Items.AddRange(command.SmtpConfigurations.Select(ToProto));
            dto.SmtpConfigurations = list;
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="DeployArtifactsCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record; unset collection wrappers rehydrate as null.</returns>
    public static DeployArtifactsCommand FromProto(DeployArtifactsCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DeployArtifactsCommand(
            dto.DeploymentId,
            dto.SharedScripts is null ? null : dto.SharedScripts.Items.Select(FromProto).ToList(),
            dto.ExternalSystems is null ? null : dto.ExternalSystems.Items.Select(FromProto).ToList(),
            dto.DatabaseConnections is null ? null : dto.DatabaseConnections.Items.Select(FromProto).ToList(),
            dto.NotificationLists is null ? null : dto.NotificationLists.Items.Select(FromProto).ToList(),
            dto.DataConnections is null ? null : dto.DataConnections.Items.Select(FromProto).ToList(),
            dto.SmtpConfigurations is null ? null : dto.SmtpConfigurations.Items.Select(FromProto).ToList(),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="SharedScriptArtifact"/> onto the wire.</summary>
    /// <param name="artifact">The artifact to project.</param>
    /// <returns>The wire message.</returns>
    public static SharedScriptArtifactDto ToProto(SharedScriptArtifact artifact)
    {
        ArgumentNullException.ThrowIfNull(artifact);

        return new SharedScriptArtifactDto
        {
            Name = artifact.Name,
            Code = artifact.Code,
            ParameterDefinitions = OrEmpty(artifact.ParameterDefinitions),
            ReturnDefinition = OrEmpty(artifact.ReturnDefinition)
        };
    }

    /// <summary>Rehydrates a <see cref="SharedScriptArtifact"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded artifact.</returns>
    public static SharedScriptArtifact FromProto(SharedScriptArtifactDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SharedScriptArtifact(
            dto.Name, dto.Code, NullIfEmpty(dto.ParameterDefinitions), NullIfEmpty(dto.ReturnDefinition));
    }

    /// <summary>Projects an <see cref="ExternalSystemArtifact"/> onto the wire.</summary>
    /// <param name="artifact">The artifact to project.</param>
    /// <returns>The wire message.</returns>
    public static ExternalSystemArtifactDto ToProto(ExternalSystemArtifact artifact)
    {
        ArgumentNullException.ThrowIfNull(artifact);

        return new ExternalSystemArtifactDto
        {
            Name = artifact.Name,
            EndpointUrl = artifact.EndpointUrl,
            AuthType = artifact.AuthType,
            AuthConfiguration = OrEmpty(artifact.AuthConfiguration),
            MethodDefinitionsJson = OrEmpty(artifact.MethodDefinitionsJson),
            TimeoutSeconds = artifact.TimeoutSeconds
        };
    }

    /// <summary>Rehydrates an <see cref="ExternalSystemArtifact"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded artifact.</returns>
    public static ExternalSystemArtifact FromProto(ExternalSystemArtifactDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ExternalSystemArtifact(
            dto.Name,
            dto.EndpointUrl,
            dto.AuthType,
            NullIfEmpty(dto.AuthConfiguration),
            NullIfEmpty(dto.MethodDefinitionsJson),
            dto.TimeoutSeconds);
    }

    /// <summary>Projects a <see cref="DatabaseConnectionArtifact"/> onto the wire.</summary>
    /// <param name="artifact">The artifact to project.</param>
    /// <returns>The wire message.</returns>
    public static DatabaseConnectionArtifactDto ToProto(DatabaseConnectionArtifact artifact)
    {
        ArgumentNullException.ThrowIfNull(artifact);

        return new DatabaseConnectionArtifactDto
        {
            Name = artifact.Name,
            ConnectionString = artifact.ConnectionString,
            MaxRetries = artifact.MaxRetries,
            RetryDelay = Duration.FromTimeSpan(artifact.RetryDelay)
        };
    }

    /// <summary>Rehydrates a <see cref="DatabaseConnectionArtifact"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded artifact.</returns>
    public static DatabaseConnectionArtifact FromProto(DatabaseConnectionArtifactDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DatabaseConnectionArtifact(
            dto.Name, dto.ConnectionString, dto.MaxRetries, dto.RetryDelay?.ToTimeSpan() ?? TimeSpan.Zero);
    }

    /// <summary>Projects a <see cref="NotificationListArtifact"/> onto the wire.</summary>
    /// <param name="artifact">The artifact to project.</param>
    /// <returns>The wire message.</returns>
    public static NotificationListArtifactDto ToProto(NotificationListArtifact artifact)
    {
        ArgumentNullException.ThrowIfNull(artifact);

        var dto = new NotificationListArtifactDto { Name = artifact.Name };
        dto.RecipientEmails.AddRange(artifact.RecipientEmails);
        return dto;
    }

    /// <summary>Rehydrates a <see cref="NotificationListArtifact"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded artifact.</returns>
    public static NotificationListArtifact FromProto(NotificationListArtifactDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new NotificationListArtifact(dto.Name, dto.RecipientEmails.ToList());
    }

    /// <summary>Projects a <see cref="DataConnectionArtifact"/> onto the wire.</summary>
    /// <param name="artifact">The artifact to project.</param>
    /// <returns>The wire message.</returns>
    public static DataConnectionArtifactDto ToProto(DataConnectionArtifact artifact)
    {
        ArgumentNullException.ThrowIfNull(artifact);

        return new DataConnectionArtifactDto
        {
            Name = artifact.Name,
            Protocol = artifact.Protocol,
            PrimaryConfigurationJson = OrEmpty(artifact.PrimaryConfigurationJson),
            BackupConfigurationJson = OrEmpty(artifact.BackupConfigurationJson),
            FailoverRetryCount = artifact.FailoverRetryCount
        };
    }

    /// <summary>Rehydrates a <see cref="DataConnectionArtifact"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded artifact.</returns>
    public static DataConnectionArtifact FromProto(DataConnectionArtifactDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DataConnectionArtifact(
            dto.Name,
            dto.Protocol,
            NullIfEmpty(dto.PrimaryConfigurationJson),
            NullIfEmpty(dto.BackupConfigurationJson),
            dto.FailoverRetryCount);
    }

    /// <summary>Projects an <see cref="SmtpConfigurationArtifact"/> onto the wire.</summary>
    /// <param name="artifact">The artifact to project.</param>
    /// <returns>The wire message.</returns>
    public static SmtpConfigurationArtifactDto ToProto(SmtpConfigurationArtifact artifact)
    {
        ArgumentNullException.ThrowIfNull(artifact);

        return new SmtpConfigurationArtifactDto
        {
            Name = artifact.Name,
            Server = artifact.Server,
            Port = artifact.Port,
            AuthMode = artifact.AuthMode,
            FromAddress = artifact.FromAddress,
            Username = OrEmpty(artifact.Username),
            Password = OrEmpty(artifact.Password),
            OauthConfig = OrEmpty(artifact.OAuthConfig)
        };
    }

    /// <summary>Rehydrates an <see cref="SmtpConfigurationArtifact"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded artifact.</returns>
    public static SmtpConfigurationArtifact FromProto(SmtpConfigurationArtifactDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SmtpConfigurationArtifact(
            dto.Name,
            dto.Server,
            dto.Port,
            dto.AuthMode,
            dto.FromAddress,
            NullIfEmpty(dto.Username),
            NullIfEmpty(dto.Password),
            NullIfEmpty(dto.OauthConfig));
    }

    // ── Lifecycle replies ──

    /// <summary>Projects a <see cref="DeploymentStatusResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static DeploymentStatusResponseDto ToProto(DeploymentStatusResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new DeploymentStatusResponseDto
        {
            DeploymentId = response.DeploymentId,
            InstanceUniqueName = response.InstanceUniqueName,
            Status = ToProto(response.Status),
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="DeploymentStatusResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static DeploymentStatusResponse FromProto(DeploymentStatusResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DeploymentStatusResponse(
            dto.DeploymentId,
            dto.InstanceUniqueName,
            FromProto(dto.Status),
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects an <see cref="InstanceLifecycleResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static InstanceLifecycleResponseDto ToProto(InstanceLifecycleResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new InstanceLifecycleResponseDto
        {
            CommandId = response.CommandId,
            InstanceUniqueName = response.InstanceUniqueName,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates an <see cref="InstanceLifecycleResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static InstanceLifecycleResponse FromProto(InstanceLifecycleResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new InstanceLifecycleResponse(
            dto.CommandId,
            dto.InstanceUniqueName,
            dto.Success,
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="DeploymentStateQueryResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static DeploymentStateQueryResponseDto ToProto(DeploymentStateQueryResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new DeploymentStateQueryResponseDto
        {
            CorrelationId = response.CorrelationId,
            InstanceUniqueName = response.InstanceUniqueName,
            IsDeployed = response.IsDeployed,
            AppliedDeploymentId = OrEmpty(response.AppliedDeploymentId),
            AppliedRevisionHash = OrEmpty(response.AppliedRevisionHash),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="DeploymentStateQueryResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static DeploymentStateQueryResponse FromProto(DeploymentStateQueryResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DeploymentStateQueryResponse(
            dto.CorrelationId,
            dto.InstanceUniqueName,
            dto.IsDeployed,
            NullIfEmpty(dto.AppliedDeploymentId),
            NullIfEmpty(dto.AppliedRevisionHash),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects an <see cref="ArtifactDeploymentResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static ArtifactDeploymentResponseDto ToProto(ArtifactDeploymentResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new ArtifactDeploymentResponseDto
        {
            DeploymentId = response.DeploymentId,
            SiteId = response.SiteId,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates an <see cref="ArtifactDeploymentResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static ArtifactDeploymentResponse FromProto(ArtifactDeploymentResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ArtifactDeploymentResponse(
            dto.DeploymentId, dto.SiteId, dto.Success, NullIfEmpty(dto.ErrorMessage), Dto(dto.Timestamp));
    }

    // ─────────────────────────────────────────────────────────────────────
    // RPC 2 — ExecuteOpcUa: envelopes
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Packs an OPC UA-group command into its request envelope.</summary>
    /// <param name="command">One of the eight OPC UA-group commands.</param>
    /// <returns>The populated request envelope.</returns>
    /// <exception cref="ArgumentException">The command does not belong to this group.</exception>
    public static OpcUaRequest ToOpcUaRequest(object command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return command switch
        {
            BrowseNodeCommand c => new OpcUaRequest { BrowseNode = ToProto(c) },
            SearchAddressSpaceCommand c => new OpcUaRequest { SearchAddressSpace = ToProto(c) },
            ReadTagValuesCommand c => new OpcUaRequest { ReadTagValues = ToProto(c) },
            VerifyEndpointCommand c => new OpcUaRequest { VerifyEndpoint = ToProto(c) },
            TrustServerCertCommand c => new OpcUaRequest { TrustServerCert = ToProto(c) },
            ListServerCertsCommand c => new OpcUaRequest { ListServerCerts = ToProto(c) },
            RemoveServerCertCommand c => new OpcUaRequest { RemoveServerCert = ToProto(c) },
            WriteTagRequest c => new OpcUaRequest { WriteTag = ToProto(c) },
            _ => throw NotInGroup(command, SiteCommandGroup.OpcUa)
        };
    }

    /// <summary>Unpacks an OPC UA-group request envelope back to its command record.</summary>
    /// <param name="request">The request envelope received on the wire.</param>
    /// <returns>The decoded command record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromOpcUaRequest(OpcUaRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return request.CommandCase switch
        {
            OpcUaRequest.CommandOneofCase.BrowseNode => FromProto(request.BrowseNode),
            OpcUaRequest.CommandOneofCase.SearchAddressSpace => FromProto(request.SearchAddressSpace),
            OpcUaRequest.CommandOneofCase.ReadTagValues => FromProto(request.ReadTagValues),
            OpcUaRequest.CommandOneofCase.VerifyEndpoint => FromProto(request.VerifyEndpoint),
            OpcUaRequest.CommandOneofCase.TrustServerCert => FromProto(request.TrustServerCert),
            OpcUaRequest.CommandOneofCase.ListServerCerts => FromProto(request.ListServerCerts),
            OpcUaRequest.CommandOneofCase.RemoveServerCert => FromProto(request.RemoveServerCert),
            OpcUaRequest.CommandOneofCase.WriteTag => FromProto(request.WriteTag),
            _ => throw UnknownCase(request.CommandCase, nameof(OpcUaRequest))
        };
    }

    /// <summary>Packs an OPC UA-group reply into its reply envelope.</summary>
    /// <param name="reply">One of the six OPC UA-group reply records.</param>
    /// <returns>The populated reply envelope.</returns>
    /// <exception cref="ArgumentException">The reply does not belong to this group.</exception>
    public static OpcUaReply ToOpcUaReply(object reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply switch
        {
            BrowseNodeResult r => new OpcUaReply { BrowseNode = ToProto(r) },
            SearchAddressSpaceResult r => new OpcUaReply { SearchAddressSpace = ToProto(r) },
            ReadTagValuesResult r => new OpcUaReply { ReadTagValues = ToProto(r) },
            VerifyEndpointResult r => new OpcUaReply { VerifyEndpoint = ToProto(r) },
            CertTrustResult r => new OpcUaReply { CertTrust = ToProto(r) },
            WriteTagResponse r => new OpcUaReply { WriteTag = ToProto(r) },
            _ => throw NotInGroup(reply, SiteCommandGroup.OpcUa)
        };
    }

    /// <summary>Unpacks an OPC UA-group reply envelope back to its reply record.</summary>
    /// <param name="reply">The reply envelope received on the wire.</param>
    /// <returns>The decoded reply record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromOpcUaReply(OpcUaReply reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply.ReplyCase switch
        {
            OpcUaReply.ReplyOneofCase.BrowseNode => FromProto(reply.BrowseNode),
            OpcUaReply.ReplyOneofCase.SearchAddressSpace => FromProto(reply.SearchAddressSpace),
            OpcUaReply.ReplyOneofCase.ReadTagValues => FromProto(reply.ReadTagValues),
            OpcUaReply.ReplyOneofCase.VerifyEndpoint => FromProto(reply.VerifyEndpoint),
            OpcUaReply.ReplyOneofCase.CertTrust => FromProto(reply.CertTrust),
            OpcUaReply.ReplyOneofCase.WriteTag => FromProto(reply.WriteTag),
            _ => throw UnknownCase(reply.ReplyCase, nameof(OpcUaReply))
        };
    }

    // ── OPC UA commands ──

    /// <summary>Projects a <see cref="BrowseNodeCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static BrowseNodeCommandDto ToProto(BrowseNodeCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new BrowseNodeCommandDto
        {
            ConnectionName = command.ConnectionName,
            ParentNodeId = OrEmpty(command.ParentNodeId),
            ContinuationToken = OrEmpty(command.ContinuationToken),
            SiteIdentifier = OrEmpty(command.SiteIdentifier)
        };
    }

    /// <summary>Rehydrates a <see cref="BrowseNodeCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static BrowseNodeCommand FromProto(BrowseNodeCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new BrowseNodeCommand(
            dto.ConnectionName,
            NullIfEmpty(dto.ParentNodeId),
            NullIfEmpty(dto.ContinuationToken),
            NullIfEmpty(dto.SiteIdentifier));
    }

    /// <summary>Projects a <see cref="SearchAddressSpaceCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static SearchAddressSpaceCommandDto ToProto(SearchAddressSpaceCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new SearchAddressSpaceCommandDto
        {
            ConnectionName = command.ConnectionName,
            Query = command.Query,
            MaxDepth = command.MaxDepth,
            MaxResults = command.MaxResults,
            SiteIdentifier = OrEmpty(command.SiteIdentifier)
        };
    }

    /// <summary>Rehydrates a <see cref="SearchAddressSpaceCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static SearchAddressSpaceCommand FromProto(SearchAddressSpaceCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SearchAddressSpaceCommand(
            dto.ConnectionName, dto.Query, dto.MaxDepth, dto.MaxResults, NullIfEmpty(dto.SiteIdentifier));
    }

    /// <summary>Projects a <see cref="ReadTagValuesCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static ReadTagValuesCommandDto ToProto(ReadTagValuesCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        var dto = new ReadTagValuesCommandDto { ConnectionName = command.ConnectionName };
        dto.TagPaths.AddRange(command.TagPaths);
        return dto;
    }

    /// <summary>Rehydrates a <see cref="ReadTagValuesCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static ReadTagValuesCommand FromProto(ReadTagValuesCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ReadTagValuesCommand(dto.ConnectionName, dto.TagPaths.ToList());
    }

    /// <summary>Projects a <see cref="VerifyEndpointCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static VerifyEndpointCommandDto ToProto(VerifyEndpointCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new VerifyEndpointCommandDto
        {
            ConnectionName = command.ConnectionName,
            Protocol = command.Protocol,
            ConfigJson = command.ConfigJson,
            SiteIdentifier = OrEmpty(command.SiteIdentifier)
        };
    }

    /// <summary>Rehydrates a <see cref="VerifyEndpointCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static VerifyEndpointCommand FromProto(VerifyEndpointCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new VerifyEndpointCommand(
            dto.ConnectionName, dto.Protocol, dto.ConfigJson, NullIfEmpty(dto.SiteIdentifier));
    }

    /// <summary>Projects a <see cref="TrustServerCertCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static TrustServerCertCommandDto ToProto(TrustServerCertCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new TrustServerCertCommandDto
        {
            ConnectionName = command.ConnectionName,
            DerBase64 = command.DerBase64,
            Thumbprint = command.Thumbprint,
            SiteIdentifier = OrEmpty(command.SiteIdentifier)
        };
    }

    /// <summary>Rehydrates a <see cref="TrustServerCertCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static TrustServerCertCommand FromProto(TrustServerCertCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new TrustServerCertCommand(
            dto.ConnectionName, dto.DerBase64, dto.Thumbprint, NullIfEmpty(dto.SiteIdentifier));
    }

    /// <summary>Projects a <see cref="ListServerCertsCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static ListServerCertsCommandDto ToProto(ListServerCertsCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new ListServerCertsCommandDto { SiteIdentifier = OrEmpty(command.SiteIdentifier) };
    }

    /// <summary>Rehydrates a <see cref="ListServerCertsCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static ListServerCertsCommand FromProto(ListServerCertsCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ListServerCertsCommand(NullIfEmpty(dto.SiteIdentifier));
    }

    /// <summary>Projects a <see cref="RemoveServerCertCommand"/> onto the wire.</summary>
    /// <param name="command">The command to project.</param>
    /// <returns>The wire message.</returns>
    public static RemoveServerCertCommandDto ToProto(RemoveServerCertCommand command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new RemoveServerCertCommandDto
        {
            Thumbprint = command.Thumbprint,
            SiteIdentifier = OrEmpty(command.SiteIdentifier)
        };
    }

    /// <summary>Rehydrates a <see cref="RemoveServerCertCommand"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded command record.</returns>
    public static RemoveServerCertCommand FromProto(RemoveServerCertCommandDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RemoveServerCertCommand(dto.Thumbprint, NullIfEmpty(dto.SiteIdentifier));
    }

    /// <summary>Projects a <see cref="WriteTagRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static WriteTagRequestDto ToProto(WriteTagRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new WriteTagRequestDto
        {
            CorrelationId = request.CorrelationId,
            ConnectionName = request.ConnectionName,
            TagPath = request.TagPath,
            Value = LooseValueCodec.ToProtoOrNull(request.Value),
            Timestamp = Ts(request.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="WriteTagRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static WriteTagRequest FromProto(WriteTagRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new WriteTagRequest(
            dto.CorrelationId,
            dto.ConnectionName,
            dto.TagPath,
            LooseValueCodec.FromProto(dto.Value),
            Dto(dto.Timestamp));
    }

    // ── OPC UA replies ──

    /// <summary>Projects a <see cref="BrowseNodeResult"/> onto the wire.</summary>
    /// <param name="result">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static BrowseNodeResultDto ToProto(BrowseNodeResult result)
    {
        ArgumentNullException.ThrowIfNull(result);

        var dto = new BrowseNodeResultDto
        {
            Truncated = result.Truncated,
            ContinuationToken = OrEmpty(result.ContinuationToken)
        };
        dto.Children.AddRange(result.Children.Select(ToProto));
        if (result.Failure is not null)
        {
            dto.Failure = ToProto(result.Failure);
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="BrowseNodeResult"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static BrowseNodeResult FromProto(BrowseNodeResultDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new BrowseNodeResult(
            dto.Children.Select(FromProto).ToList(),
            dto.Truncated,
            dto.Failure is null ? null : FromProto(dto.Failure),
            NullIfEmpty(dto.ContinuationToken));
    }

    /// <summary>Projects a <see cref="BrowseNode"/> onto the wire.</summary>
    /// <param name="node">The address-space node to project.</param>
    /// <returns>The wire message.</returns>
    public static BrowseNodeDto ToProto(BrowseNode node)
    {
        ArgumentNullException.ThrowIfNull(node);

        var dto = new BrowseNodeDto
        {
            NodeId = node.NodeId,
            DisplayName = node.DisplayName,
            NodeClass = ToProto(node.NodeClass),
            HasChildren = node.HasChildren,
            DataType = OrEmpty(node.DataType)
        };

        if (node.ValueRank.HasValue)
        {
            dto.ValueRank = node.ValueRank.Value;
        }

        if (node.Writable.HasValue)
        {
            dto.Writable = node.Writable.Value;
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="BrowseNode"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded address-space node.</returns>
    public static BrowseNode FromProto(BrowseNodeDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new BrowseNode(
            dto.NodeId,
            dto.DisplayName,
            FromProto(dto.NodeClass),
            dto.HasChildren,
            NullIfEmpty(dto.DataType),
            dto.ValueRank,
            dto.Writable);
    }

    /// <summary>Projects a <see cref="BrowseFailure"/> onto the wire.</summary>
    /// <param name="failure">The structured browse failure to project.</param>
    /// <returns>The wire message.</returns>
    public static BrowseFailureDto ToProto(BrowseFailure failure)
    {
        ArgumentNullException.ThrowIfNull(failure);

        return new BrowseFailureDto { Kind = ToProto(failure.Kind), Message = failure.Message };
    }

    /// <summary>Rehydrates a <see cref="BrowseFailure"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded structured browse failure.</returns>
    public static BrowseFailure FromProto(BrowseFailureDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new BrowseFailure(FromProto(dto.Kind), dto.Message);
    }

    /// <summary>Projects a <see cref="SearchAddressSpaceResult"/> onto the wire.</summary>
    /// <param name="result">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static SearchAddressSpaceResultDto ToProto(SearchAddressSpaceResult result)
    {
        ArgumentNullException.ThrowIfNull(result);

        var dto = new SearchAddressSpaceResultDto { CapReached = result.CapReached };
        dto.Matches.AddRange(result.Matches.Select(ToProto));
        if (result.Failure is not null)
        {
            dto.Failure = ToProto(result.Failure);
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="SearchAddressSpaceResult"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static SearchAddressSpaceResult FromProto(SearchAddressSpaceResultDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SearchAddressSpaceResult(
            dto.Matches.Select(FromProto).ToList(),
            dto.CapReached,
            dto.Failure is null ? null : FromProto(dto.Failure));
    }

    /// <summary>Projects an <see cref="AddressSpaceMatch"/> onto the wire.</summary>
    /// <param name="match">The search match to project.</param>
    /// <returns>The wire message.</returns>
    public static AddressSpaceMatchDto ToProto(AddressSpaceMatch match)
    {
        ArgumentNullException.ThrowIfNull(match);

        return new AddressSpaceMatchDto { Node = ToProto(match.Node), Path = match.Path };
    }

    /// <summary>Rehydrates an <see cref="AddressSpaceMatch"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded search match.</returns>
    public static AddressSpaceMatch FromProto(AddressSpaceMatchDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new AddressSpaceMatch(FromProto(dto.Node), dto.Path);
    }

    /// <summary>Projects a <see cref="ReadTagValuesResult"/> onto the wire.</summary>
    /// <param name="result">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static ReadTagValuesResultDto ToProto(ReadTagValuesResult result)
    {
        ArgumentNullException.ThrowIfNull(result);

        var dto = new ReadTagValuesResultDto();
        dto.Outcomes.AddRange(result.Outcomes.Select(ToProto));
        if (result.Failure is not null)
        {
            dto.Failure = ToProto(result.Failure);
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="ReadTagValuesResult"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static ReadTagValuesResult FromProto(ReadTagValuesResultDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ReadTagValuesResult(
            dto.Outcomes.Select(FromProto).ToList(),
            dto.Failure is null ? null : FromProto(dto.Failure));
    }

    /// <summary>Projects a <see cref="TagReadOutcome"/> onto the wire.</summary>
    /// <param name="outcome">The per-tag read outcome to project.</param>
    /// <returns>The wire message.</returns>
    public static TagReadOutcomeDto ToProto(TagReadOutcome outcome)
    {
        ArgumentNullException.ThrowIfNull(outcome);

        return new TagReadOutcomeDto
        {
            TagPath = outcome.TagPath,
            Success = outcome.Success,
            Value = LooseValueCodec.ToProtoOrNull(outcome.Value),
            Quality = outcome.Quality,
            Timestamp = Ts(outcome.Timestamp),
            ErrorMessage = OrEmpty(outcome.ErrorMessage)
        };
    }

    /// <summary>Rehydrates a <see cref="TagReadOutcome"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded per-tag read outcome.</returns>
    public static TagReadOutcome FromProto(TagReadOutcomeDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new TagReadOutcome(
            dto.TagPath,
            dto.Success,
            LooseValueCodec.FromProto(dto.Value),
            dto.Quality,
            Dto(dto.Timestamp),
            NullIfEmpty(dto.ErrorMessage));
    }

    /// <summary>Projects a <see cref="ReadTagValuesFailure"/> onto the wire.</summary>
    /// <param name="failure">The connection-level read failure to project.</param>
    /// <returns>The wire message.</returns>
    public static ReadTagValuesFailureDto ToProto(ReadTagValuesFailure failure)
    {
        ArgumentNullException.ThrowIfNull(failure);

        return new ReadTagValuesFailureDto { Kind = ToProto(failure.Kind), Message = failure.Message };
    }

    /// <summary>Rehydrates a <see cref="ReadTagValuesFailure"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded connection-level read failure.</returns>
    public static ReadTagValuesFailure FromProto(ReadTagValuesFailureDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ReadTagValuesFailure(FromProto(dto.Kind), dto.Message);
    }

    /// <summary>Projects a <see cref="VerifyEndpointResult"/> onto the wire.</summary>
    /// <param name="result">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static VerifyEndpointResultDto ToProto(VerifyEndpointResult result)
    {
        ArgumentNullException.ThrowIfNull(result);

        var dto = new VerifyEndpointResultDto
        {
            Success = result.Success,
            Error = OrEmpty(result.Error)
        };

        if (result.FailureKind.HasValue)
        {
            dto.FailureKind = new VerifyFailureKindValue { Value = ToProto(result.FailureKind.Value) };
        }

        if (result.Cert is not null)
        {
            dto.Cert = ToProto(result.Cert);
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="VerifyEndpointResult"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static VerifyEndpointResult FromProto(VerifyEndpointResultDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new VerifyEndpointResult(
            dto.Success,
            dto.FailureKind is null ? null : FromProto(dto.FailureKind.Value),
            NullIfEmpty(dto.Error),
            dto.Cert is null ? null : FromProto(dto.Cert));
    }

    /// <summary>Projects a <see cref="ServerCertInfo"/> onto the wire.</summary>
    /// <param name="cert">The captured server certificate to project.</param>
    /// <returns>The wire message.</returns>
    public static ServerCertInfoDto ToProto(ServerCertInfo cert)
    {
        ArgumentNullException.ThrowIfNull(cert);

        return new ServerCertInfoDto
        {
            Thumbprint = cert.Thumbprint,
            Subject = cert.Subject,
            Issuer = cert.Issuer,
            NotBeforeUtc = TsUtc(cert.NotBeforeUtc),
            NotAfterUtc = TsUtc(cert.NotAfterUtc),
            DerBase64 = cert.DerBase64
        };
    }

    /// <summary>Rehydrates a <see cref="ServerCertInfo"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded captured server certificate.</returns>
    public static ServerCertInfo FromProto(ServerCertInfoDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ServerCertInfo(
            dto.Thumbprint,
            dto.Subject,
            dto.Issuer,
            DtUtc(dto.NotBeforeUtc),
            DtUtc(dto.NotAfterUtc),
            dto.DerBase64);
    }

    /// <summary>Projects a <see cref="CertTrustResult"/> onto the wire.</summary>
    /// <remarks>A null <see cref="CertTrustResult.Certs"/> (trust/remove) leaves the list wrapper unset.</remarks>
    /// <param name="result">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static CertTrustResultDto ToProto(CertTrustResult result)
    {
        ArgumentNullException.ThrowIfNull(result);

        var dto = new CertTrustResultDto { Success = result.Success, Error = OrEmpty(result.Error) };
        if (result.Certs is not null)
        {
            var list = new TrustedCertInfoListDto();
            list.Items.AddRange(result.Certs.Select(ToProto));
            dto.Certs = list;
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="CertTrustResult"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static CertTrustResult FromProto(CertTrustResultDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new CertTrustResult(
            dto.Success,
            NullIfEmpty(dto.Error),
            dto.Certs is null ? null : dto.Certs.Items.Select(FromProto).ToList());
    }

    /// <summary>Projects a <see cref="TrustedCertInfo"/> onto the wire.</summary>
    /// <param name="cert">The PKI store entry to project.</param>
    /// <returns>The wire message.</returns>
    public static TrustedCertInfoDto ToProto(TrustedCertInfo cert)
    {
        ArgumentNullException.ThrowIfNull(cert);

        return new TrustedCertInfoDto
        {
            Thumbprint = cert.Thumbprint,
            Subject = cert.Subject,
            Issuer = cert.Issuer,
            NotBeforeUtc = TsUtc(cert.NotBeforeUtc),
            NotAfterUtc = TsUtc(cert.NotAfterUtc),
            Rejected = cert.Rejected
        };
    }

    /// <summary>Rehydrates a <see cref="TrustedCertInfo"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded PKI store entry.</returns>
    public static TrustedCertInfo FromProto(TrustedCertInfoDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new TrustedCertInfo(
            dto.Thumbprint,
            dto.Subject,
            dto.Issuer,
            DtUtc(dto.NotBeforeUtc),
            DtUtc(dto.NotAfterUtc),
            dto.Rejected);
    }

    /// <summary>Projects a <see cref="WriteTagResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static WriteTagResponseDto ToProto(WriteTagResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new WriteTagResponseDto
        {
            CorrelationId = response.CorrelationId,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="WriteTagResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static WriteTagResponse FromProto(WriteTagResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new WriteTagResponse(
            dto.CorrelationId, dto.Success, NullIfEmpty(dto.ErrorMessage), Dto(dto.Timestamp));
    }

    // ─────────────────────────────────────────────────────────────────────
    // RPC 3 — ExecuteQuery: envelopes
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Packs a query-group command into its request envelope.</summary>
    /// <param name="command">One of the four query-group commands.</param>
    /// <returns>The populated request envelope.</returns>
    /// <exception cref="ArgumentException">The command does not belong to this group.</exception>
    public static QueryRequest ToQueryRequest(object command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return command switch
        {
            EventLogQueryRequest c => new QueryRequest { EventLogQuery = ToProto(c) },
            DebugSnapshotRequest c => new QueryRequest { DebugSnapshot = ToProto(c) },
            SubscribeDebugViewRequest c => new QueryRequest { SubscribeDebugView = ToProto(c) },
            UnsubscribeDebugViewRequest c => new QueryRequest { UnsubscribeDebugView = ToProto(c) },
            _ => throw NotInGroup(command, SiteCommandGroup.Query)
        };
    }

    /// <summary>Unpacks a query-group request envelope back to its command record.</summary>
    /// <param name="request">The request envelope received on the wire.</param>
    /// <returns>The decoded command record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromQueryRequest(QueryRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return request.CommandCase switch
        {
            QueryRequest.CommandOneofCase.EventLogQuery => FromProto(request.EventLogQuery),
            QueryRequest.CommandOneofCase.DebugSnapshot => FromProto(request.DebugSnapshot),
            QueryRequest.CommandOneofCase.SubscribeDebugView => FromProto(request.SubscribeDebugView),
            QueryRequest.CommandOneofCase.UnsubscribeDebugView => FromProto(request.UnsubscribeDebugView),
            _ => throw UnknownCase(request.CommandCase, nameof(QueryRequest))
        };
    }

    /// <summary>
    /// Packs a query-group reply into its reply envelope. Pass
    /// <see cref="UnsubscribeDebugViewAck"/> for the unsubscribe path, which is a
    /// Tell today and carries no payload.
    /// </summary>
    /// <param name="reply">One of the query-group reply records.</param>
    /// <returns>The populated reply envelope.</returns>
    /// <exception cref="ArgumentException">The reply does not belong to this group.</exception>
    public static QueryReply ToQueryReply(object reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply switch
        {
            EventLogQueryResponse r => new QueryReply { EventLogQuery = ToProto(r) },
            DebugViewSnapshot r => new QueryReply { DebugViewSnapshot = ToProto(r) },
            UnsubscribeDebugViewAck => new QueryReply { UnsubscribeDebugView = new UnsubscribeDebugViewAckDto() },
            _ => throw NotInGroup(reply, SiteCommandGroup.Query)
        };
    }

    /// <summary>Unpacks a query-group reply envelope back to its reply record.</summary>
    /// <param name="reply">The reply envelope received on the wire.</param>
    /// <returns>The decoded reply record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromQueryReply(QueryReply reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply.ReplyCase switch
        {
            QueryReply.ReplyOneofCase.EventLogQuery => FromProto(reply.EventLogQuery),
            QueryReply.ReplyOneofCase.DebugViewSnapshot => FromProto(reply.DebugViewSnapshot),
            QueryReply.ReplyOneofCase.UnsubscribeDebugView => UnsubscribeDebugViewAck.Instance,
            _ => throw UnknownCase(reply.ReplyCase, nameof(QueryReply))
        };
    }

    // ── Query commands ──

    /// <summary>Projects an <see cref="EventLogQueryRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static EventLogQueryRequestDto ToProto(EventLogQueryRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        var dto = new EventLogQueryRequestDto
        {
            CorrelationId = request.CorrelationId,
            SiteId = request.SiteId,
            EventType = OrEmpty(request.EventType),
            Severity = OrEmpty(request.Severity),
            InstanceId = OrEmpty(request.InstanceId),
            KeywordFilter = OrEmpty(request.KeywordFilter),
            ContinuationToken = OrEmpty(request.ContinuationToken),
            PageSize = request.PageSize,
            Timestamp = Ts(request.Timestamp)
        };

        if (request.From.HasValue)
        {
            dto.From = Ts(request.From.Value);
        }

        if (request.To.HasValue)
        {
            dto.To = Ts(request.To.Value);
        }

        return dto;
    }

    /// <summary>Rehydrates an <see cref="EventLogQueryRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static EventLogQueryRequest FromProto(EventLogQueryRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new EventLogQueryRequest(
            dto.CorrelationId,
            dto.SiteId,
            DtoOrNull(dto.From),
            DtoOrNull(dto.To),
            NullIfEmpty(dto.EventType),
            NullIfEmpty(dto.Severity),
            NullIfEmpty(dto.InstanceId),
            NullIfEmpty(dto.KeywordFilter),
            NullIfEmpty(dto.ContinuationToken),
            dto.PageSize,
            Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="DebugSnapshotRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static DebugSnapshotRequestDto ToProto(DebugSnapshotRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new DebugSnapshotRequestDto
        {
            InstanceUniqueName = request.InstanceUniqueName,
            CorrelationId = request.CorrelationId,
            // Additive field 3 — proto3 false default means an older site that does
            // not know the field simply returns the full snapshot (correct, just fatter).
            AlarmsOnly = request.AlarmsOnly
        };
    }

    /// <summary>Rehydrates a <see cref="DebugSnapshotRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static DebugSnapshotRequest FromProto(DebugSnapshotRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DebugSnapshotRequest(dto.InstanceUniqueName, dto.CorrelationId, dto.AlarmsOnly);
    }

    /// <summary>Projects a <see cref="SubscribeDebugViewRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static SubscribeDebugViewRequestDto ToProto(SubscribeDebugViewRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new SubscribeDebugViewRequestDto
        {
            InstanceUniqueName = request.InstanceUniqueName,
            CorrelationId = request.CorrelationId
        };
    }

    /// <summary>Rehydrates a <see cref="SubscribeDebugViewRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static SubscribeDebugViewRequest FromProto(SubscribeDebugViewRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SubscribeDebugViewRequest(dto.InstanceUniqueName, dto.CorrelationId);
    }

    /// <summary>Projects an <see cref="UnsubscribeDebugViewRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static UnsubscribeDebugViewRequestDto ToProto(UnsubscribeDebugViewRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new UnsubscribeDebugViewRequestDto
        {
            InstanceUniqueName = request.InstanceUniqueName,
            CorrelationId = request.CorrelationId
        };
    }

    /// <summary>Rehydrates an <see cref="UnsubscribeDebugViewRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static UnsubscribeDebugViewRequest FromProto(UnsubscribeDebugViewRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new UnsubscribeDebugViewRequest(dto.InstanceUniqueName, dto.CorrelationId);
    }

    // ── Query replies ──

    /// <summary>Projects an <see cref="EventLogQueryResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static EventLogQueryResponseDto ToProto(EventLogQueryResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        var dto = new EventLogQueryResponseDto
        {
            CorrelationId = response.CorrelationId,
            SiteId = response.SiteId,
            ContinuationToken = OrEmpty(response.ContinuationToken),
            HasMore = response.HasMore,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
        dto.Entries.AddRange(response.Entries.Select(ToProto));
        return dto;
    }

    /// <summary>Rehydrates an <see cref="EventLogQueryResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static EventLogQueryResponse FromProto(EventLogQueryResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new EventLogQueryResponse(
            dto.CorrelationId,
            dto.SiteId,
            dto.Entries.Select(FromProto).ToList(),
            NullIfEmpty(dto.ContinuationToken),
            dto.HasMore,
            dto.Success,
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects an <see cref="EventLogEntry"/> onto the wire.</summary>
    /// <param name="entry">The event-log row to project.</param>
    /// <returns>The wire message.</returns>
    public static EventLogEntryDto ToProto(EventLogEntry entry)
    {
        ArgumentNullException.ThrowIfNull(entry);

        return new EventLogEntryDto
        {
            Id = entry.Id,
            Timestamp = Ts(entry.Timestamp),
            EventType = entry.EventType,
            Severity = entry.Severity,
            InstanceId = OrEmpty(entry.InstanceId),
            Source = entry.Source,
            Message = entry.Message,
            Details = OrEmpty(entry.Details)
        };
    }

    /// <summary>Rehydrates an <see cref="EventLogEntry"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded event-log row.</returns>
    public static EventLogEntry FromProto(EventLogEntryDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new EventLogEntry(
            dto.Id,
            Dto(dto.Timestamp),
            dto.EventType,
            dto.Severity,
            NullIfEmpty(dto.InstanceId),
            dto.Source,
            dto.Message,
            NullIfEmpty(dto.Details));
    }

    /// <summary>Projects a <see cref="DebugViewSnapshot"/> onto the wire.</summary>
    /// <param name="snapshot">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static DebugViewSnapshotDto ToProto(DebugViewSnapshot snapshot)
    {
        ArgumentNullException.ThrowIfNull(snapshot);

        var dto = new DebugViewSnapshotDto
        {
            InstanceUniqueName = snapshot.InstanceUniqueName,
            SnapshotTimestamp = Ts(snapshot.SnapshotTimestamp),
            InstanceNotFound = snapshot.InstanceNotFound
        };
        dto.AttributeValues.AddRange(snapshot.AttributeValues.Select(ToProto));
        dto.AlarmStates.AddRange(snapshot.AlarmStates.Select(ToProto));
        return dto;
    }

    /// <summary>Rehydrates a <see cref="DebugViewSnapshot"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static DebugViewSnapshot FromProto(DebugViewSnapshotDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DebugViewSnapshot(
            dto.InstanceUniqueName,
            dto.AttributeValues.Select(FromProto).ToList(),
            dto.AlarmStates.Select(FromProto).ToList(),
            Dto(dto.SnapshotTimestamp),
            dto.InstanceNotFound);
    }

    /// <summary>Projects an <see cref="AttributeValueChanged"/> snapshot row onto the wire.</summary>
    /// <param name="value">The attribute snapshot row to project.</param>
    /// <returns>The wire message.</returns>
    public static DebugAttributeValueDto ToProto(AttributeValueChanged value)
    {
        ArgumentNullException.ThrowIfNull(value);

        return new DebugAttributeValueDto
        {
            InstanceUniqueName = value.InstanceUniqueName,
            AttributePath = value.AttributePath,
            AttributeName = value.AttributeName,
            Value = LooseValueCodec.ToProtoOrNull(value.Value),
            Quality = value.Quality,
            Timestamp = Ts(value.Timestamp)
        };
    }

    /// <summary>Rehydrates an <see cref="AttributeValueChanged"/> snapshot row from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded attribute snapshot row.</returns>
    public static AttributeValueChanged FromProto(DebugAttributeValueDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new AttributeValueChanged(
            dto.InstanceUniqueName,
            dto.AttributePath,
            dto.AttributeName,
            LooseValueCodec.FromProto(dto.Value),
            dto.Quality,
            Dto(dto.Timestamp));
    }

    /// <summary>Projects an <see cref="AlarmStateChanged"/> snapshot row onto the wire.</summary>
    /// <remarks>
    /// <see cref="AlarmStateChanged.Condition"/> is only written when it differs
    /// from the value the record itself derives from <c>State</c> + <c>Priority</c>.
    /// The record stores the condition in a nullable backing field and synthesises
    /// the computed default on read, so writing it unconditionally would turn an
    /// implicit condition into an explicit one — indistinguishable through the
    /// property, but a difference under record equality. Omitting the redundant
    /// value keeps the round trip exact for computed alarms, which are the common
    /// case, and is information-preserving for the rest.
    /// </remarks>
    /// <param name="alarm">The alarm snapshot row to project.</param>
    /// <returns>The wire message.</returns>
    public static DebugAlarmStateDto ToProto(AlarmStateChanged alarm)
    {
        ArgumentNullException.ThrowIfNull(alarm);

        var dto = new DebugAlarmStateDto
        {
            InstanceUniqueName = alarm.InstanceUniqueName,
            AlarmName = alarm.AlarmName,
            State = ToProto(alarm.State),
            Priority = alarm.Priority,
            Timestamp = Ts(alarm.Timestamp),
            Level = ToProto(alarm.Level),
            Message = alarm.Message,
            Kind = ToProto(alarm.Kind),
            SourceReference = alarm.SourceReference,
            AlarmTypeName = alarm.AlarmTypeName,
            Category = alarm.Category,
            OperatorUser = alarm.OperatorUser,
            OperatorComment = alarm.OperatorComment,
            CurrentValue = alarm.CurrentValue,
            LimitValue = alarm.LimitValue,
            NativeSourceCanonicalName = alarm.NativeSourceCanonicalName,
            IsConfiguredPlaceholder = alarm.IsConfiguredPlaceholder
        };

        if (alarm.Condition != AlarmConditionStateFactory.ForComputed(alarm.State, alarm.Priority))
        {
            dto.Condition = ToProto(alarm.Condition);
        }

        if (alarm.OriginalRaiseTime.HasValue)
        {
            dto.OriginalRaiseTime = Ts(alarm.OriginalRaiseTime.Value);
        }

        return dto;
    }

    /// <summary>Rehydrates an <see cref="AlarmStateChanged"/> snapshot row from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded alarm snapshot row.</returns>
    public static AlarmStateChanged FromProto(DebugAlarmStateDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        var alarm = new AlarmStateChanged(
            dto.InstanceUniqueName,
            dto.AlarmName,
            FromProto(dto.State),
            dto.Priority,
            Dto(dto.Timestamp))
        {
            Level = FromProto(dto.Level),
            Message = dto.Message,
            Kind = FromProto(dto.Kind),
            SourceReference = dto.SourceReference,
            AlarmTypeName = dto.AlarmTypeName,
            Category = dto.Category,
            OperatorUser = dto.OperatorUser,
            OperatorComment = dto.OperatorComment,
            OriginalRaiseTime = DtoOrNull(dto.OriginalRaiseTime),
            CurrentValue = dto.CurrentValue,
            LimitValue = dto.LimitValue,
            NativeSourceCanonicalName = dto.NativeSourceCanonicalName,
            IsConfiguredPlaceholder = dto.IsConfiguredPlaceholder
        };

        return dto.Condition is null ? alarm : alarm with { Condition = FromProto(dto.Condition) };
    }

    /// <summary>Projects an <see cref="AlarmConditionState"/> onto the wire.</summary>
    /// <param name="condition">The unified alarm condition to project.</param>
    /// <returns>The wire message.</returns>
    public static AlarmConditionStateDto ToProto(AlarmConditionState condition)
    {
        ArgumentNullException.ThrowIfNull(condition);

        var dto = new AlarmConditionStateDto
        {
            Active = condition.Active,
            Acknowledged = condition.Acknowledged,
            Shelve = ToProto(condition.Shelve),
            Suppressed = condition.Suppressed,
            Severity = condition.Severity
        };

        if (condition.Confirmed.HasValue)
        {
            dto.Confirmed = condition.Confirmed.Value;
        }

        return dto;
    }

    /// <summary>Rehydrates an <see cref="AlarmConditionState"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded unified alarm condition.</returns>
    public static AlarmConditionState FromProto(AlarmConditionStateDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new AlarmConditionState(
            dto.Active,
            dto.Acknowledged,
            dto.Confirmed,
            FromProto(dto.Shelve),
            dto.Suppressed,
            dto.Severity);
    }

    // ─────────────────────────────────────────────────────────────────────
    // RPC 4 — ExecuteParked: envelopes
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Packs a parked-group command into its request envelope.</summary>
    /// <param name="command">One of the five parked-group commands.</param>
    /// <returns>The populated request envelope.</returns>
    /// <exception cref="ArgumentException">The command does not belong to this group.</exception>
    public static ParkedRequest ToParkedRequest(object command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return command switch
        {
            ParkedMessageQueryRequest c => new ParkedRequest { ParkedMessageQuery = ToProto(c) },
            ParkedMessageRetryRequest c => new ParkedRequest { ParkedMessageRetry = ToProto(c) },
            ParkedMessageDiscardRequest c => new ParkedRequest { ParkedMessageDiscard = ToProto(c) },
            RetryParkedOperation c => new ParkedRequest { RetryParkedOperation = ToProto(c) },
            DiscardParkedOperation c => new ParkedRequest { DiscardParkedOperation = ToProto(c) },
            _ => throw NotInGroup(command, SiteCommandGroup.Parked)
        };
    }

    /// <summary>Unpacks a parked-group request envelope back to its command record.</summary>
    /// <param name="request">The request envelope received on the wire.</param>
    /// <returns>The decoded command record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromParkedRequest(ParkedRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return request.CommandCase switch
        {
            ParkedRequest.CommandOneofCase.ParkedMessageQuery => FromProto(request.ParkedMessageQuery),
            ParkedRequest.CommandOneofCase.ParkedMessageRetry => FromProto(request.ParkedMessageRetry),
            ParkedRequest.CommandOneofCase.ParkedMessageDiscard => FromProto(request.ParkedMessageDiscard),
            ParkedRequest.CommandOneofCase.RetryParkedOperation => FromProto(request.RetryParkedOperation),
            ParkedRequest.CommandOneofCase.DiscardParkedOperation => FromProto(request.DiscardParkedOperation),
            _ => throw UnknownCase(request.CommandCase, nameof(ParkedRequest))
        };
    }

    /// <summary>Packs a parked-group reply into its reply envelope.</summary>
    /// <param name="reply">One of the four parked-group reply records.</param>
    /// <returns>The populated reply envelope.</returns>
    /// <exception cref="ArgumentException">The reply does not belong to this group.</exception>
    public static ParkedReply ToParkedReply(object reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply switch
        {
            ParkedMessageQueryResponse r => new ParkedReply { ParkedMessageQuery = ToProto(r) },
            ParkedMessageRetryResponse r => new ParkedReply { ParkedMessageRetry = ToProto(r) },
            ParkedMessageDiscardResponse r => new ParkedReply { ParkedMessageDiscard = ToProto(r) },
            ParkedOperationActionAck r => new ParkedReply { ParkedOperationAction = ToProto(r) },
            _ => throw NotInGroup(reply, SiteCommandGroup.Parked)
        };
    }

    /// <summary>Unpacks a parked-group reply envelope back to its reply record.</summary>
    /// <param name="reply">The reply envelope received on the wire.</param>
    /// <returns>The decoded reply record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromParkedReply(ParkedReply reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply.ReplyCase switch
        {
            ParkedReply.ReplyOneofCase.ParkedMessageQuery => FromProto(reply.ParkedMessageQuery),
            ParkedReply.ReplyOneofCase.ParkedMessageRetry => FromProto(reply.ParkedMessageRetry),
            ParkedReply.ReplyOneofCase.ParkedMessageDiscard => FromProto(reply.ParkedMessageDiscard),
            ParkedReply.ReplyOneofCase.ParkedOperationAction => FromProto(reply.ParkedOperationAction),
            _ => throw UnknownCase(reply.ReplyCase, nameof(ParkedReply))
        };
    }

    // ── Parked commands ──

    /// <summary>Projects a <see cref="ParkedMessageQueryRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageQueryRequestDto ToProto(ParkedMessageQueryRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new ParkedMessageQueryRequestDto
        {
            CorrelationId = request.CorrelationId,
            SiteId = request.SiteId,
            PageNumber = request.PageNumber,
            PageSize = request.PageSize,
            Timestamp = Ts(request.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageQueryRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static ParkedMessageQueryRequest FromProto(ParkedMessageQueryRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageQueryRequest(
            dto.CorrelationId, dto.SiteId, dto.PageNumber, dto.PageSize, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="ParkedMessageRetryRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageRetryRequestDto ToProto(ParkedMessageRetryRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new ParkedMessageRetryRequestDto
        {
            CorrelationId = request.CorrelationId,
            SiteId = request.SiteId,
            MessageId = request.MessageId,
            Timestamp = Ts(request.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageRetryRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static ParkedMessageRetryRequest FromProto(ParkedMessageRetryRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageRetryRequest(
            dto.CorrelationId, dto.SiteId, dto.MessageId, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="ParkedMessageDiscardRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageDiscardRequestDto ToProto(ParkedMessageDiscardRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new ParkedMessageDiscardRequestDto
        {
            CorrelationId = request.CorrelationId,
            SiteId = request.SiteId,
            MessageId = request.MessageId,
            Timestamp = Ts(request.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageDiscardRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static ParkedMessageDiscardRequest FromProto(ParkedMessageDiscardRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageDiscardRequest(
            dto.CorrelationId, dto.SiteId, dto.MessageId, Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="RetryParkedOperation"/> relay onto the wire.</summary>
    /// <param name="command">The relay command to project.</param>
    /// <returns>The wire message.</returns>
    public static RetryParkedOperationDto ToProto(RetryParkedOperation command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new RetryParkedOperationDto
        {
            CorrelationId = command.CorrelationId,
            TrackedOperationId = command.TrackedOperationId.ToString()
        };
    }

    /// <summary>Rehydrates a <see cref="RetryParkedOperation"/> relay from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded relay command.</returns>
    public static RetryParkedOperation FromProto(RetryParkedOperationDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RetryParkedOperation(dto.CorrelationId, TrackedId(dto.TrackedOperationId));
    }

    /// <summary>Projects a <see cref="DiscardParkedOperation"/> relay onto the wire.</summary>
    /// <param name="command">The relay command to project.</param>
    /// <returns>The wire message.</returns>
    public static DiscardParkedOperationDto ToProto(DiscardParkedOperation command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new DiscardParkedOperationDto
        {
            CorrelationId = command.CorrelationId,
            TrackedOperationId = command.TrackedOperationId.ToString()
        };
    }

    /// <summary>Rehydrates a <see cref="DiscardParkedOperation"/> relay from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded relay command.</returns>
    public static DiscardParkedOperation FromProto(DiscardParkedOperationDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new DiscardParkedOperation(dto.CorrelationId, TrackedId(dto.TrackedOperationId));
    }

    // ── Parked replies ──

    /// <summary>Projects a <see cref="ParkedMessageQueryResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageQueryResponseDto ToProto(ParkedMessageQueryResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        var dto = new ParkedMessageQueryResponseDto
        {
            CorrelationId = response.CorrelationId,
            SiteId = response.SiteId,
            TotalCount = response.TotalCount,
            PageNumber = response.PageNumber,
            PageSize = response.PageSize,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
        dto.Messages.AddRange(response.Messages.Select(ToProto));
        return dto;
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageQueryResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static ParkedMessageQueryResponse FromProto(ParkedMessageQueryResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageQueryResponse(
            dto.CorrelationId,
            dto.SiteId,
            dto.Messages.Select(FromProto).ToList(),
            dto.TotalCount,
            dto.PageNumber,
            dto.PageSize,
            dto.Success,
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="ParkedMessageEntry"/> onto the wire.</summary>
    /// <param name="entry">The parked store-and-forward row to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageEntryDto ToProto(ParkedMessageEntry entry)
    {
        ArgumentNullException.ThrowIfNull(entry);

        return new ParkedMessageEntryDto
        {
            MessageId = entry.MessageId,
            TargetSystem = entry.TargetSystem,
            MethodName = entry.MethodName,
            ErrorMessage = entry.ErrorMessage,
            AttemptCount = entry.AttemptCount,
            OriginalTimestamp = Ts(entry.OriginalTimestamp),
            LastAttemptTimestamp = Ts(entry.LastAttemptTimestamp),
            MaxAttempts = entry.MaxAttempts,
            Category = ToProto(entry.Category),
            OriginInstance = OrEmpty(entry.OriginInstance)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageEntry"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded parked store-and-forward row.</returns>
    public static ParkedMessageEntry FromProto(ParkedMessageEntryDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageEntry(
            dto.MessageId,
            dto.TargetSystem,
            dto.MethodName,
            dto.ErrorMessage,
            dto.AttemptCount,
            Dto(dto.OriginalTimestamp),
            Dto(dto.LastAttemptTimestamp),
            dto.MaxAttempts,
            FromProto(dto.Category),
            NullIfEmpty(dto.OriginInstance));
    }

    /// <summary>Projects a <see cref="ParkedMessageRetryResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageRetryResponseDto ToProto(ParkedMessageRetryResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new ParkedMessageRetryResponseDto
        {
            CorrelationId = response.CorrelationId,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageRetryResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static ParkedMessageRetryResponse FromProto(ParkedMessageRetryResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageRetryResponse(dto.CorrelationId, dto.Success, NullIfEmpty(dto.ErrorMessage));
    }

    /// <summary>Projects a <see cref="ParkedMessageDiscardResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedMessageDiscardResponseDto ToProto(ParkedMessageDiscardResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new ParkedMessageDiscardResponseDto
        {
            CorrelationId = response.CorrelationId,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedMessageDiscardResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static ParkedMessageDiscardResponse FromProto(ParkedMessageDiscardResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedMessageDiscardResponse(dto.CorrelationId, dto.Success, NullIfEmpty(dto.ErrorMessage));
    }

    /// <summary>Projects a <see cref="ParkedOperationActionAck"/> onto the wire.</summary>
    /// <param name="ack">The relay ack to project.</param>
    /// <returns>The wire message.</returns>
    public static ParkedOperationActionAckDto ToProto(ParkedOperationActionAck ack)
    {
        ArgumentNullException.ThrowIfNull(ack);

        return new ParkedOperationActionAckDto
        {
            CorrelationId = ack.CorrelationId,
            Applied = ack.Applied,
            ErrorMessage = OrEmpty(ack.ErrorMessage)
        };
    }

    /// <summary>Rehydrates a <see cref="ParkedOperationActionAck"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded relay ack.</returns>
    public static ParkedOperationActionAck FromProto(ParkedOperationActionAckDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new ParkedOperationActionAck(dto.CorrelationId, dto.Applied, NullIfEmpty(dto.ErrorMessage));
    }

    // ─────────────────────────────────────────────────────────────────────
    // RPC 5 — ExecuteRoute: envelopes
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Packs a route-group command into its request envelope.</summary>
    /// <param name="command">One of the four route-group commands.</param>
    /// <returns>The populated request envelope.</returns>
    /// <exception cref="ArgumentException">The command does not belong to this group.</exception>
    public static RouteRequest ToRouteRequest(object command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return command switch
        {
            RouteToCallRequest c => new RouteRequest { RouteToCall = ToProto(c) },
            RouteToGetAttributesRequest c => new RouteRequest { RouteToGetAttributes = ToProto(c) },
            RouteToSetAttributesRequest c => new RouteRequest { RouteToSetAttributes = ToProto(c) },
            RouteToWaitForAttributeRequest c => new RouteRequest { RouteToWaitForAttribute = ToProto(c) },
            _ => throw NotInGroup(command, SiteCommandGroup.Route)
        };
    }

    /// <summary>Unpacks a route-group request envelope back to its command record.</summary>
    /// <param name="request">The request envelope received on the wire.</param>
    /// <returns>The decoded command record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromRouteRequest(RouteRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return request.CommandCase switch
        {
            RouteRequest.CommandOneofCase.RouteToCall => FromProto(request.RouteToCall),
            RouteRequest.CommandOneofCase.RouteToGetAttributes => FromProto(request.RouteToGetAttributes),
            RouteRequest.CommandOneofCase.RouteToSetAttributes => FromProto(request.RouteToSetAttributes),
            RouteRequest.CommandOneofCase.RouteToWaitForAttribute => FromProto(request.RouteToWaitForAttribute),
            _ => throw UnknownCase(request.CommandCase, nameof(RouteRequest))
        };
    }

    /// <summary>Packs a route-group reply into its reply envelope.</summary>
    /// <param name="reply">One of the four route-group reply records.</param>
    /// <returns>The populated reply envelope.</returns>
    /// <exception cref="ArgumentException">The reply does not belong to this group.</exception>
    public static RouteReply ToRouteReply(object reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply switch
        {
            RouteToCallResponse r => new RouteReply { RouteToCall = ToProto(r) },
            RouteToGetAttributesResponse r => new RouteReply { RouteToGetAttributes = ToProto(r) },
            RouteToSetAttributesResponse r => new RouteReply { RouteToSetAttributes = ToProto(r) },
            RouteToWaitForAttributeResponse r => new RouteReply { RouteToWaitForAttribute = ToProto(r) },
            _ => throw NotInGroup(reply, SiteCommandGroup.Route)
        };
    }

    /// <summary>Unpacks a route-group reply envelope back to its reply record.</summary>
    /// <param name="reply">The reply envelope received on the wire.</param>
    /// <returns>The decoded reply record.</returns>
    /// <exception cref="NotSupportedException">The envelope carries no case this build knows.</exception>
    public static object FromRouteReply(RouteReply reply)
    {
        ArgumentNullException.ThrowIfNull(reply);

        return reply.ReplyCase switch
        {
            RouteReply.ReplyOneofCase.RouteToCall => FromProto(reply.RouteToCall),
            RouteReply.ReplyOneofCase.RouteToGetAttributes => FromProto(reply.RouteToGetAttributes),
            RouteReply.ReplyOneofCase.RouteToSetAttributes => FromProto(reply.RouteToSetAttributes),
            RouteReply.ReplyOneofCase.RouteToWaitForAttribute => FromProto(reply.RouteToWaitForAttribute),
            _ => throw UnknownCase(reply.ReplyCase, nameof(RouteReply))
        };
    }

    // ── Route commands ──

    /// <summary>Projects a <see cref="RouteToCallRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToCallRequestDto ToProto(RouteToCallRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        return new RouteToCallRequestDto
        {
            CorrelationId = request.CorrelationId,
            InstanceUniqueName = request.InstanceUniqueName,
            ScriptName = request.ScriptName,
            Parameters = LooseValueCodec.ToProtoMapOrNull(request.Parameters),
            Timestamp = Ts(request.Timestamp),
            ParentExecutionId = GuidToWire(request.ParentExecutionId)
        };
    }

    /// <summary>Rehydrates a <see cref="RouteToCallRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static RouteToCallRequest FromProto(RouteToCallRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToCallRequest(
            dto.CorrelationId,
            dto.InstanceUniqueName,
            dto.ScriptName,
            LooseValueCodec.FromProtoMapOrNull(dto.Parameters),
            Dto(dto.Timestamp),
            GuidFromWire(dto.ParentExecutionId));
    }

    /// <summary>Projects a <see cref="RouteToGetAttributesRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToGetAttributesRequestDto ToProto(RouteToGetAttributesRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        var dto = new RouteToGetAttributesRequestDto
        {
            CorrelationId = request.CorrelationId,
            InstanceUniqueName = request.InstanceUniqueName,
            Timestamp = Ts(request.Timestamp),
            ParentExecutionId = GuidToWire(request.ParentExecutionId)
        };
        dto.AttributeNames.AddRange(request.AttributeNames);
        return dto;
    }

    /// <summary>Rehydrates a <see cref="RouteToGetAttributesRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static RouteToGetAttributesRequest FromProto(RouteToGetAttributesRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToGetAttributesRequest(
            dto.CorrelationId,
            dto.InstanceUniqueName,
            dto.AttributeNames.ToList(),
            Dto(dto.Timestamp),
            GuidFromWire(dto.ParentExecutionId));
    }

    /// <summary>Projects a <see cref="RouteToSetAttributesRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToSetAttributesRequestDto ToProto(RouteToSetAttributesRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        var dto = new RouteToSetAttributesRequestDto
        {
            CorrelationId = request.CorrelationId,
            InstanceUniqueName = request.InstanceUniqueName,
            Timestamp = Ts(request.Timestamp),
            ParentExecutionId = GuidToWire(request.ParentExecutionId)
        };

        foreach (var (key, value) in request.AttributeValues)
        {
            dto.AttributeValues[key] = value;
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="RouteToSetAttributesRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static RouteToSetAttributesRequest FromProto(RouteToSetAttributesRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToSetAttributesRequest(
            dto.CorrelationId,
            dto.InstanceUniqueName,
            dto.AttributeValues.ToDictionary(kv => kv.Key, kv => kv.Value),
            Dto(dto.Timestamp),
            GuidFromWire(dto.ParentExecutionId));
    }

    /// <summary>Projects a <see cref="RouteToWaitForAttributeRequest"/> onto the wire.</summary>
    /// <param name="request">The request to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToWaitForAttributeRequestDto ToProto(RouteToWaitForAttributeRequest request)
    {
        ArgumentNullException.ThrowIfNull(request);

        var dto = new RouteToWaitForAttributeRequestDto
        {
            CorrelationId = request.CorrelationId,
            InstanceUniqueName = request.InstanceUniqueName,
            AttributeName = request.AttributeName,
            Timeout = Duration.FromTimeSpan(request.Timeout),
            Timestamp = Ts(request.Timestamp),
            ParentExecutionId = GuidToWire(request.ParentExecutionId),
            RequireGoodQuality = request.RequireGoodQuality
        };

        if (request.TargetValueEncoded is not null)
        {
            dto.TargetValueEncoded = request.TargetValueEncoded;
        }

        return dto;
    }

    /// <summary>Rehydrates a <see cref="RouteToWaitForAttributeRequest"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded request record.</returns>
    public static RouteToWaitForAttributeRequest FromProto(RouteToWaitForAttributeRequestDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToWaitForAttributeRequest(
            dto.CorrelationId,
            dto.InstanceUniqueName,
            dto.AttributeName,
            dto.TargetValueEncoded,
            dto.Timeout?.ToTimeSpan() ?? TimeSpan.Zero,
            Dto(dto.Timestamp),
            GuidFromWire(dto.ParentExecutionId),
            dto.RequireGoodQuality);
    }

    // ── Route replies ──

    /// <summary>Projects a <see cref="RouteToCallResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToCallResponseDto ToProto(RouteToCallResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new RouteToCallResponseDto
        {
            CorrelationId = response.CorrelationId,
            Success = response.Success,
            ReturnValue = LooseValueCodec.ToProtoOrNull(response.ReturnValue),
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="RouteToCallResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static RouteToCallResponse FromProto(RouteToCallResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToCallResponse(
            dto.CorrelationId,
            dto.Success,
            LooseValueCodec.FromProto(dto.ReturnValue),
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="RouteToGetAttributesResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToGetAttributesResponseDto ToProto(RouteToGetAttributesResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new RouteToGetAttributesResponseDto
        {
            CorrelationId = response.CorrelationId,
            Values = LooseValueCodec.ToProtoMap(response.Values),
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="RouteToGetAttributesResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static RouteToGetAttributesResponse FromProto(RouteToGetAttributesResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToGetAttributesResponse(
            dto.CorrelationId,
            LooseValueCodec.FromProtoMap(dto.Values),
            dto.Success,
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="RouteToSetAttributesResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToSetAttributesResponseDto ToProto(RouteToSetAttributesResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new RouteToSetAttributesResponseDto
        {
            CorrelationId = response.CorrelationId,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="RouteToSetAttributesResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static RouteToSetAttributesResponse FromProto(RouteToSetAttributesResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToSetAttributesResponse(
            dto.CorrelationId, dto.Success, NullIfEmpty(dto.ErrorMessage), Dto(dto.Timestamp));
    }

    /// <summary>Projects a <see cref="RouteToWaitForAttributeResponse"/> onto the wire.</summary>
    /// <param name="response">The reply to project.</param>
    /// <returns>The wire message.</returns>
    public static RouteToWaitForAttributeResponseDto ToProto(RouteToWaitForAttributeResponse response)
    {
        ArgumentNullException.ThrowIfNull(response);

        return new RouteToWaitForAttributeResponseDto
        {
            CorrelationId = response.CorrelationId,
            Matched = response.Matched,
            Value = LooseValueCodec.ToProtoOrNull(response.Value),
            Quality = OrEmpty(response.Quality),
            TimedOut = response.TimedOut,
            Success = response.Success,
            ErrorMessage = OrEmpty(response.ErrorMessage),
            Timestamp = Ts(response.Timestamp)
        };
    }

    /// <summary>Rehydrates a <see cref="RouteToWaitForAttributeResponse"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded reply record.</returns>
    public static RouteToWaitForAttributeResponse FromProto(RouteToWaitForAttributeResponseDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new RouteToWaitForAttributeResponse(
            dto.CorrelationId,
            dto.Matched,
            LooseValueCodec.FromProto(dto.Value),
            NullIfEmpty(dto.Quality),
            dto.TimedOut,
            dto.Success,
            NullIfEmpty(dto.ErrorMessage),
            Dto(dto.Timestamp));
    }

    // ─────────────────────────────────────────────────────────────────────
    // RPC 6 — TriggerFailover
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Projects a <see cref="TriggerSiteFailover"/> onto the wire.</summary>
    /// <param name="command">The relay command to project.</param>
    /// <returns>The wire message.</returns>
    public static TriggerSiteFailoverDto ToProto(TriggerSiteFailover command)
    {
        ArgumentNullException.ThrowIfNull(command);

        return new TriggerSiteFailoverDto
        {
            CorrelationId = command.CorrelationId,
            SiteId = command.SiteId
        };
    }

    /// <summary>Rehydrates a <see cref="TriggerSiteFailover"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded relay command.</returns>
    public static TriggerSiteFailover FromProto(TriggerSiteFailoverDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new TriggerSiteFailover(dto.CorrelationId, dto.SiteId);
    }

    /// <summary>Projects a <see cref="SiteFailoverAck"/> onto the wire.</summary>
    /// <param name="ack">The relay ack to project.</param>
    /// <returns>The wire message.</returns>
    public static SiteFailoverAckDto ToProto(SiteFailoverAck ack)
    {
        ArgumentNullException.ThrowIfNull(ack);

        return new SiteFailoverAckDto
        {
            CorrelationId = ack.CorrelationId,
            Accepted = ack.Accepted,
            TargetAddress = OrEmpty(ack.TargetAddress),
            ErrorMessage = OrEmpty(ack.ErrorMessage)
        };
    }

    /// <summary>Rehydrates a <see cref="SiteFailoverAck"/> from the wire.</summary>
    /// <param name="dto">The wire message to decode.</param>
    /// <returns>The decoded relay ack.</returns>
    public static SiteFailoverAck FromProto(SiteFailoverAckDto dto)
    {
        ArgumentNullException.ThrowIfNull(dto);

        return new SiteFailoverAck(
            dto.CorrelationId,
            dto.Accepted,
            NullIfEmpty(dto.TargetAddress),
            NullIfEmpty(dto.ErrorMessage));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Enum translation — explicit both ways, never by ordinal.
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Projects a <see cref="DeploymentStatus"/> onto its wire enum.</summary>
    /// <param name="status">The status to project.</param>
    /// <returns>The wire enum value.</returns>
    public static DeploymentStatusDto ToProto(DeploymentStatus status) => status switch
    {
        DeploymentStatus.Pending => DeploymentStatusDto.Pending,
        DeploymentStatus.InProgress => DeploymentStatusDto.InProgress,
        DeploymentStatus.Success => DeploymentStatusDto.Success,
        DeploymentStatus.Failed => DeploymentStatusDto.Failed,
        _ => DeploymentStatusDto.Unspecified
    };

    /// <summary>Rehydrates a <see cref="DeploymentStatus"/>; unknown decodes to <c>Failed</c> (never claim success).</summary>
    /// <param name="status">The wire enum value.</param>
    /// <returns>The decoded status.</returns>
    public static DeploymentStatus FromProto(DeploymentStatusDto status) => status switch
    {
        DeploymentStatusDto.Pending => DeploymentStatus.Pending,
        DeploymentStatusDto.InProgress => DeploymentStatus.InProgress,
        DeploymentStatusDto.Success => DeploymentStatus.Success,
        _ => DeploymentStatus.Failed
    };

    /// <summary>Projects a <see cref="BrowseNodeClass"/> onto its wire enum.</summary>
    /// <param name="nodeClass">The node class to project.</param>
    /// <returns>The wire enum value.</returns>
    public static BrowseNodeClassDto ToProto(BrowseNodeClass nodeClass) => nodeClass switch
    {
        BrowseNodeClass.Object => BrowseNodeClassDto.Object,
        BrowseNodeClass.Variable => BrowseNodeClassDto.Variable,
        BrowseNodeClass.Method => BrowseNodeClassDto.Method,
        BrowseNodeClass.Other => BrowseNodeClassDto.Other,
        _ => BrowseNodeClassDto.Unspecified
    };

    /// <summary>Rehydrates a <see cref="BrowseNodeClass"/>; unknown decodes to <c>Other</c>.</summary>
    /// <param name="nodeClass">The wire enum value.</param>
    /// <returns>The decoded node class.</returns>
    public static BrowseNodeClass FromProto(BrowseNodeClassDto nodeClass) => nodeClass switch
    {
        BrowseNodeClassDto.Object => BrowseNodeClass.Object,
        BrowseNodeClassDto.Variable => BrowseNodeClass.Variable,
        BrowseNodeClassDto.Method => BrowseNodeClass.Method,
        _ => BrowseNodeClass.Other
    };

    /// <summary>Projects a <see cref="BrowseFailureKind"/> onto its wire enum.</summary>
    /// <param name="kind">The failure kind to project.</param>
    /// <returns>The wire enum value.</returns>
    public static BrowseFailureKindDto ToProto(BrowseFailureKind kind) => kind switch
    {
        BrowseFailureKind.ConnectionNotFound => BrowseFailureKindDto.ConnectionNotFound,
        BrowseFailureKind.ConnectionNotConnected => BrowseFailureKindDto.ConnectionNotConnected,
        BrowseFailureKind.NotBrowsable => BrowseFailureKindDto.NotBrowsable,
        BrowseFailureKind.Timeout => BrowseFailureKindDto.Timeout,
        BrowseFailureKind.ServerError => BrowseFailureKindDto.ServerError,
        _ => BrowseFailureKindDto.Unspecified
    };

    /// <summary>Rehydrates a <see cref="BrowseFailureKind"/>; unknown decodes to <c>ServerError</c>.</summary>
    /// <param name="kind">The wire enum value.</param>
    /// <returns>The decoded failure kind.</returns>
    public static BrowseFailureKind FromProto(BrowseFailureKindDto kind) => kind switch
    {
        BrowseFailureKindDto.ConnectionNotFound => BrowseFailureKind.ConnectionNotFound,
        BrowseFailureKindDto.ConnectionNotConnected => BrowseFailureKind.ConnectionNotConnected,
        BrowseFailureKindDto.NotBrowsable => BrowseFailureKind.NotBrowsable,
        BrowseFailureKindDto.Timeout => BrowseFailureKind.Timeout,
        _ => BrowseFailureKind.ServerError
    };

    /// <summary>Projects a <see cref="ReadTagValuesFailureKind"/> onto its wire enum.</summary>
    /// <param name="kind">The failure kind to project.</param>
    /// <returns>The wire enum value.</returns>
    public static ReadTagValuesFailureKindDto ToProto(ReadTagValuesFailureKind kind) => kind switch
    {
        ReadTagValuesFailureKind.ConnectionNotFound => ReadTagValuesFailureKindDto.ConnectionNotFound,
        ReadTagValuesFailureKind.ConnectionNotConnected => ReadTagValuesFailureKindDto.ConnectionNotConnected,
        ReadTagValuesFailureKind.Timeout => ReadTagValuesFailureKindDto.Timeout,
        ReadTagValuesFailureKind.ServerError => ReadTagValuesFailureKindDto.ServerError,
        _ => ReadTagValuesFailureKindDto.Unspecified
    };

    /// <summary>Rehydrates a <see cref="ReadTagValuesFailureKind"/>; unknown decodes to <c>ServerError</c>.</summary>
    /// <param name="kind">The wire enum value.</param>
    /// <returns>The decoded failure kind.</returns>
    public static ReadTagValuesFailureKind FromProto(ReadTagValuesFailureKindDto kind) => kind switch
    {
        ReadTagValuesFailureKindDto.ConnectionNotFound => ReadTagValuesFailureKind.ConnectionNotFound,
        ReadTagValuesFailureKindDto.ConnectionNotConnected => ReadTagValuesFailureKind.ConnectionNotConnected,
        ReadTagValuesFailureKindDto.Timeout => ReadTagValuesFailureKind.Timeout,
        _ => ReadTagValuesFailureKind.ServerError
    };

    /// <summary>Projects a <see cref="VerifyFailureKind"/> onto its wire enum.</summary>
    /// <param name="kind">The failure kind to project.</param>
    /// <returns>The wire enum value.</returns>
    public static VerifyFailureKindDto ToProto(VerifyFailureKind kind) => kind switch
    {
        VerifyFailureKind.Unreachable => VerifyFailureKindDto.Unreachable,
        VerifyFailureKind.AuthFailed => VerifyFailureKindDto.AuthFailed,
        VerifyFailureKind.UntrustedCertificate => VerifyFailureKindDto.UntrustedCertificate,
        VerifyFailureKind.Timeout => VerifyFailureKindDto.Timeout,
        VerifyFailureKind.ServerError => VerifyFailureKindDto.ServerError,
        _ => VerifyFailureKindDto.Unspecified
    };

    /// <summary>Rehydrates a <see cref="VerifyFailureKind"/>; unknown decodes to <c>ServerError</c>.</summary>
    /// <param name="kind">The wire enum value.</param>
    /// <returns>The decoded failure kind.</returns>
    public static VerifyFailureKind FromProto(VerifyFailureKindDto kind) => kind switch
    {
        VerifyFailureKindDto.Unreachable => VerifyFailureKind.Unreachable,
        VerifyFailureKindDto.AuthFailed => VerifyFailureKind.AuthFailed,
        VerifyFailureKindDto.UntrustedCertificate => VerifyFailureKind.UntrustedCertificate,
        VerifyFailureKindDto.Timeout => VerifyFailureKind.Timeout,
        _ => VerifyFailureKind.ServerError
    };

    /// <summary>Projects a <see cref="StoreAndForwardCategory"/> onto its wire enum.</summary>
    /// <param name="category">The category to project.</param>
    /// <returns>The wire enum value.</returns>
    public static StoreAndForwardCategoryDto ToProto(StoreAndForwardCategory category) => category switch
    {
        StoreAndForwardCategory.ExternalSystem => StoreAndForwardCategoryDto.ExternalSystem,
        StoreAndForwardCategory.Notification => StoreAndForwardCategoryDto.Notification,
        StoreAndForwardCategory.CachedDbWrite => StoreAndForwardCategoryDto.CachedDbWrite,
        _ => StoreAndForwardCategoryDto.Unspecified
    };

    /// <summary>Rehydrates a <see cref="StoreAndForwardCategory"/>; unknown decodes to <c>ExternalSystem</c> (the DTO's own default).</summary>
    /// <param name="category">The wire enum value.</param>
    /// <returns>The decoded category.</returns>
    public static StoreAndForwardCategory FromProto(StoreAndForwardCategoryDto category) => category switch
    {
        StoreAndForwardCategoryDto.Notification => StoreAndForwardCategory.Notification,
        StoreAndForwardCategoryDto.CachedDbWrite => StoreAndForwardCategory.CachedDbWrite,
        _ => StoreAndForwardCategory.ExternalSystem
    };

    /// <summary>Projects an <see cref="AlarmState"/> onto its wire enum.</summary>
    /// <param name="state">The alarm state to project.</param>
    /// <returns>The wire enum value.</returns>
    public static AlarmStateDto ToProto(AlarmState state) => state switch
    {
        AlarmState.Active => AlarmStateDto.Active,
        AlarmState.Normal => AlarmStateDto.Normal,
        _ => AlarmStateDto.Unspecified
    };

    /// <summary>Rehydrates an <see cref="AlarmState"/>; unknown decodes to <c>Normal</c> (never invent an alarm).</summary>
    /// <param name="state">The wire enum value.</param>
    /// <returns>The decoded alarm state.</returns>
    public static AlarmState FromProto(AlarmStateDto state) =>
        state == AlarmStateDto.Active ? AlarmState.Active : AlarmState.Normal;

    /// <summary>Projects an <see cref="AlarmLevel"/> onto its wire enum.</summary>
    /// <param name="level">The alarm level to project.</param>
    /// <returns>The wire enum value.</returns>
    public static AlarmLevelDto ToProto(AlarmLevel level) => level switch
    {
        AlarmLevel.None => AlarmLevelDto.None,
        AlarmLevel.Low => AlarmLevelDto.Low,
        AlarmLevel.LowLow => AlarmLevelDto.LowLow,
        AlarmLevel.High => AlarmLevelDto.High,
        AlarmLevel.HighHigh => AlarmLevelDto.HighHigh,
        _ => AlarmLevelDto.Unspecified
    };

    /// <summary>Rehydrates an <see cref="AlarmLevel"/>; unknown decodes to <c>None</c>.</summary>
    /// <param name="level">The wire enum value.</param>
    /// <returns>The decoded alarm level.</returns>
    public static AlarmLevel FromProto(AlarmLevelDto level) => level switch
    {
        AlarmLevelDto.Low => AlarmLevel.Low,
        AlarmLevelDto.LowLow => AlarmLevel.LowLow,
        AlarmLevelDto.High => AlarmLevel.High,
        AlarmLevelDto.HighHigh => AlarmLevel.HighHigh,
        _ => AlarmLevel.None
    };

    /// <summary>Projects an <see cref="AlarmKind"/> onto its wire enum.</summary>
    /// <param name="kind">The alarm kind to project.</param>
    /// <returns>The wire enum value.</returns>
    public static AlarmKindDto ToProto(AlarmKind kind) => kind switch
    {
        AlarmKind.Computed => AlarmKindDto.Computed,
        AlarmKind.NativeOpcUa => AlarmKindDto.NativeOpcUa,
        AlarmKind.NativeMxAccess => AlarmKindDto.NativeMxAccess,
        _ => AlarmKindDto.Unspecified
    };

    /// <summary>Rehydrates an <see cref="AlarmKind"/>; unknown decodes to <c>Computed</c> (the record's own default).</summary>
    /// <param name="kind">The wire enum value.</param>
    /// <returns>The decoded alarm kind.</returns>
    public static AlarmKind FromProto(AlarmKindDto kind) => kind switch
    {
        AlarmKindDto.NativeOpcUa => AlarmKind.NativeOpcUa,
        AlarmKindDto.NativeMxAccess => AlarmKind.NativeMxAccess,
        _ => AlarmKind.Computed
    };

    /// <summary>Projects an <see cref="AlarmShelveState"/> onto its wire enum.</summary>
    /// <param name="shelve">The shelve state to project.</param>
    /// <returns>The wire enum value.</returns>
    public static AlarmShelveStateDto ToProto(AlarmShelveState shelve) => shelve switch
    {
        AlarmShelveState.Unshelved => AlarmShelveStateDto.Unshelved,
        AlarmShelveState.OneShotShelved => AlarmShelveStateDto.OneShotShelved,
        AlarmShelveState.TimedShelved => AlarmShelveStateDto.TimedShelved,
        AlarmShelveState.PermanentShelved => AlarmShelveStateDto.PermanentShelved,
        _ => AlarmShelveStateDto.Unspecified
    };

    /// <summary>
    /// Rehydrates an <see cref="AlarmShelveState"/>; unknown decodes to
    /// <c>Unshelved</c> — the same safe default <see cref="AlarmShelveStateCodec"/>
    /// applies on the streaming path.
    /// </summary>
    /// <param name="shelve">The wire enum value.</param>
    /// <returns>The decoded shelve state.</returns>
    public static AlarmShelveState FromProto(AlarmShelveStateDto shelve) => shelve switch
    {
        AlarmShelveStateDto.OneShotShelved => AlarmShelveState.OneShotShelved,
        AlarmShelveStateDto.TimedShelved => AlarmShelveState.TimedShelved,
        AlarmShelveStateDto.PermanentShelved => AlarmShelveState.PermanentShelved,
        _ => AlarmShelveState.Unshelved
    };

    // ─────────────────────────────────────────────────────────────────────
    // Primitive helpers
    // ─────────────────────────────────────────────────────────────────────

    private static Timestamp Ts(DateTimeOffset value) => Timestamp.FromDateTimeOffset(value);

    private static DateTimeOffset Dto(Timestamp? value) =>
        value?.ToDateTimeOffset() ?? default;

    private static DateTimeOffset? DtoOrNull(Timestamp? value) =>
        value?.ToDateTimeOffset();

    private static Timestamp TsUtc(DateTime value) => Timestamp.FromDateTime(EnsureUtc(value));

    private static DateTime DtUtc(Timestamp? value) => value?.ToDateTime() ?? default;

    private static DateTime EnsureUtc(DateTime value) =>
        value.Kind == DateTimeKind.Utc
            ? value
            : DateTime.SpecifyKind(value.ToUniversalTime(), DateTimeKind.Utc);

    private static string OrEmpty(string? value) => value ?? string.Empty;

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

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

    private static Guid? GuidFromWire(string value) =>
        Guid.TryParse(value, out var parsed) ? parsed : null;

    private static TrackedOperationId TrackedId(string value) =>
        TrackedOperationId.TryParse(value, out var parsed) ? parsed : default;

    private static ArgumentException NotInGroup(object message, SiteCommandGroup group) =>
        new($"'{message.GetType().Name}' is not part of the {group} RPC group.", nameof(message));

    private static NotSupportedException UnknownCase<TCase>(TCase oneofCase, string envelope)
        where TCase : struct, Enum =>
        new($"{envelope} carries oneof case '{oneofCase}', which this build does not understand. "
            + "The peer is running a newer contract (message evolution is additive-only).");
}

/// <summary>
/// Marker reply for the fire-and-forget <c>UnsubscribeDebugView</c> command.
/// </summary>
/// <remarks>
/// The Akka path Tells the unsubscribe and expects nothing back, but a unary RPC
/// must answer something. This singleton is what the dispatcher returns and what
/// <see cref="SiteCommandDtoMapper.FromQueryReply"/> produces, so the two
/// transports present the same shape; the central transport discards it and keeps
/// the caller-visible fire-and-forget semantics.
/// </remarks>
public sealed record UnsubscribeDebugViewAck
{
    private UnsubscribeDebugViewAck()
    {
    }

    /// <summary>The single instance.</summary>
    public static UnsubscribeDebugViewAck Instance { get; } = new();
}
