using System.Reflection;
using Google.Protobuf;
using Google.Protobuf.Reflection;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Protocol;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.InboundApi;
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.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

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

/// <summary>
/// Round-trip goldens for <see cref="SiteCommandDtoMapper"/> — the 28 migrated
/// central→site commands, all 22 reply shapes, and every nested type they
/// carry, each proven to survive record → proto → record unchanged.
/// </summary>
/// <remarks>
/// <para>
/// The suite is REFLECTION-DRIVEN on purpose. Both the per-type theory and the
/// coverage guards enumerate the mapper's real surface (its <c>ToProto</c>
/// overloads) and the real proto contract (the generated <c>oneof</c>
/// descriptors), so adding a command without a golden fails the build rather
/// than quietly shipping an untested field. A hand-maintained list across 50
/// types would drift on the first change.
/// </para>
/// <para>
/// Where a round trip is not bit-exact, the mapper is the thing that gets fixed
/// — never the fixture. The two deliberate normalisations
/// (empty-string-means-null, and the implicit alarm <c>Condition</c>) are
/// asserted explicitly below rather than papered over.
/// </para>
/// </remarks>
public class SiteCommandDtoMapperGoldenTests
{
    private static readonly IReadOnlyDictionary<string, Type> TypesByName =
        SiteCommandSamples.All.Keys.ToDictionary(t => t.FullName!, t => t);

    /// <summary>One theory case per (mapped type, golden sample).</summary>
    /// <returns>Type name and sample index pairs covering every golden fixture.</returns>
    public static IEnumerable<object[]> AllSamples() =>
        SiteCommandSamples.All
            .OrderBy(kv => kv.Key.FullName, StringComparer.Ordinal)
            .SelectMany(kv => Enumerable.Range(0, kv.Value.Length)
                .Select(i => new object[] { kv.Key.FullName!, i }));

    [Theory]
    [MemberData(nameof(AllSamples))]
    public void RoundTrips_Unchanged(string typeName, int sampleIndex)
    {
        var type = TypesByName[typeName];
        var original = SiteCommandSamples.All[type][sampleIndex];

        var restored = RoundTrip(original, type);

        StructuralEquality.AssertDeepEqual(original, restored, type.Name);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Coverage guards
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Every non-enum type the mapper can project must have goldens. This is the
    /// guard that makes the suite self-maintaining.
    /// </summary>
    [Fact]
    public void EveryMappedType_HasGoldenSamples()
    {
        var missing = MappedRecordTypes()
            .Where(t => !SiteCommandSamples.All.ContainsKey(t))
            .Select(t => t.Name)
            .OrderBy(n => n, StringComparer.Ordinal)
            .ToList();

        Assert.True(
            missing.Count == 0,
            "SiteCommandDtoMapper projects types with no golden sample: " + string.Join(", ", missing));
    }

    /// <summary>Every golden sample type must have at least two samples (populated + minimal).</summary>
    [Fact]
    public void EveryGoldenType_HasAtLeastOneSample()
    {
        var empty = SiteCommandSamples.All
            .Where(kv => kv.Value.Length == 0)
            .Select(kv => kv.Key.Name)
            .ToList();

        Assert.True(empty.Count == 0, "Golden types with no samples: " + string.Join(", ", empty));
    }

    /// <summary>
    /// The contract carries exactly 28 commands across six domain groups. If the
    /// site gains a command, this count moves deliberately, not silently.
    /// </summary>
    [Fact]
    public void CommandInventory_Is28_AcrossSixGroups()
    {
        var commands = CommandSampleTypes().ToList();

        Assert.Equal(28, commands.Count);
        Assert.Equal(
            [6, 8, 4, 5, 4, 1],
            new[]
            {
                SiteCommandGroup.Lifecycle, SiteCommandGroup.OpcUa, SiteCommandGroup.Query,
                SiteCommandGroup.Parked, SiteCommandGroup.Route, SiteCommandGroup.Failover
            }.Select(g => commands.Count(t => SiteCommandDtoMapper.GroupOf(Sample(t)) == g)).ToArray());
    }

    /// <summary>The contract carries 22 reply shapes across the same six groups.</summary>
    [Fact]
    public void ReplyInventory_Is22_AcrossSixGroups()
    {
        var replies = ReplyTypes().ToList();

        Assert.Equal(22, replies.Count);
        Assert.Equal(
            [4, 6, 3, 4, 4, 1],
            new[]
            {
                SiteCommandGroup.Lifecycle, SiteCommandGroup.OpcUa, SiteCommandGroup.Query,
                SiteCommandGroup.Parked, SiteCommandGroup.Route, SiteCommandGroup.Failover
            }.Select(g => replies.Count(t => SiteCommandDtoMapper.GroupOfReply(SampleReply(t)) == g)).ToArray());
    }

    /// <summary>
    /// Packing every command golden must exercise EVERY <c>oneof</c> case
    /// declared in the four multi-command request envelopes. A new proto case
    /// with no producing command fails here.
    /// </summary>
    [Theory]
    [MemberData(nameof(RequestEnvelopes))]
    public void EveryRequestOneofCase_IsProducedByACommand(string envelopeName)
    {
        var produced = CommandSampleTypes()
            .Select(t => PackCommand(Sample(t)))
            .OfType<IMessage>()
            .Where(m => m.Descriptor.Name == envelopeName)
            .Select(OneofCaseName)
            .ToHashSet(StringComparer.Ordinal);

        var declared = DescriptorFor(envelopeName).Oneofs[0].Fields
            .Select(f => f.PropertyName)
            .ToHashSet(StringComparer.Ordinal);

        Assert.Equal(declared.OrderBy(n => n, StringComparer.Ordinal), produced.OrderBy(n => n, StringComparer.Ordinal));
    }

    /// <summary>The reply-envelope mirror of <see cref="EveryRequestOneofCase_IsProducedByACommand"/>.</summary>
    [Theory]
    [MemberData(nameof(ReplyEnvelopes))]
    public void EveryReplyOneofCase_IsProducedByAReply(string envelopeName)
    {
        var produced = ReplyTypes()
            .Select(t => PackReply(SampleReply(t)))
            .OfType<IMessage>()
            .Where(m => m.Descriptor.Name == envelopeName)
            .Select(OneofCaseName)
            .ToHashSet(StringComparer.Ordinal);

        var declared = DescriptorFor(envelopeName).Oneofs[0].Fields
            .Select(f => f.PropertyName)
            .ToHashSet(StringComparer.Ordinal);

        Assert.Equal(declared.OrderBy(n => n, StringComparer.Ordinal), produced.OrderBy(n => n, StringComparer.Ordinal));
    }

    /// <summary>Names of the four multi-command request envelopes.</summary>
    /// <returns>Envelope message names.</returns>
    public static IEnumerable<object[]> RequestEnvelopes() =>
        [["LifecycleRequest"], ["OpcUaRequest"], ["QueryRequest"], ["ParkedRequest"], ["RouteRequest"]];

    /// <summary>Names of the four multi-reply reply envelopes.</summary>
    /// <returns>Envelope message names.</returns>
    public static IEnumerable<object[]> ReplyEnvelopes() =>
        [["LifecycleReply"], ["OpcUaReply"], ["QueryReply"], ["ParkedReply"], ["RouteReply"]];

    /// <summary>Every command golden survives the full envelope pack/unpack, not just its own message.</summary>
    [Fact]
    public void EveryCommandGolden_SurvivesItsEnvelope()
    {
        foreach (var (type, samples) in SiteCommandSamples.All)
        {
            if (!IsCommand(type))
            {
                continue;
            }

            foreach (var sample in samples)
            {
                var restored = UnpackCommand(PackCommand(sample));
                StructuralEquality.AssertDeepEqual(sample, restored, $"{type.Name}(envelope)");
            }
        }
    }

    /// <summary>Every reply golden survives the full envelope pack/unpack.</summary>
    [Fact]
    public void EveryReplyGolden_SurvivesItsEnvelope()
    {
        foreach (var (type, samples) in SiteCommandSamples.All)
        {
            if (!IsReply(type))
            {
                continue;
            }

            foreach (var sample in samples)
            {
                var restored = UnpackReply(PackReply(sample));
                StructuralEquality.AssertDeepEqual(sample, restored, $"{type.Name}(envelope)");
            }
        }
    }

    /// <summary>The fire-and-forget unsubscribe ack is a real envelope case, not a special path.</summary>
    [Fact]
    public void UnsubscribeDebugViewAck_RoundTripsThroughQueryReply()
    {
        var envelope = SiteCommandDtoMapper.ToQueryReply(UnsubscribeDebugViewAck.Instance);

        Assert.Equal(QueryReply.ReplyOneofCase.UnsubscribeDebugView, envelope.ReplyCase);
        Assert.Same(UnsubscribeDebugViewAck.Instance, SiteCommandDtoMapper.FromQueryReply(envelope));
    }

    // ─────────────────────────────────────────────────────────────────────
    // Enum exhaustiveness
    // ─────────────────────────────────────────────────────────────────────

    [Theory]
    [InlineData(DeploymentStatus.Pending)]
    [InlineData(DeploymentStatus.InProgress)]
    [InlineData(DeploymentStatus.Success)]
    [InlineData(DeploymentStatus.Failed)]
    public void DeploymentStatus_RoundTrips(DeploymentStatus value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(BrowseNodeClass.Object)]
    [InlineData(BrowseNodeClass.Variable)]
    [InlineData(BrowseNodeClass.Method)]
    [InlineData(BrowseNodeClass.Other)]
    public void BrowseNodeClass_RoundTrips(BrowseNodeClass value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(BrowseFailureKind.ConnectionNotFound)]
    [InlineData(BrowseFailureKind.ConnectionNotConnected)]
    [InlineData(BrowseFailureKind.NotBrowsable)]
    [InlineData(BrowseFailureKind.Timeout)]
    [InlineData(BrowseFailureKind.ServerError)]
    public void BrowseFailureKind_RoundTrips(BrowseFailureKind value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(ReadTagValuesFailureKind.ConnectionNotFound)]
    [InlineData(ReadTagValuesFailureKind.ConnectionNotConnected)]
    [InlineData(ReadTagValuesFailureKind.Timeout)]
    [InlineData(ReadTagValuesFailureKind.ServerError)]
    public void ReadTagValuesFailureKind_RoundTrips(ReadTagValuesFailureKind value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(VerifyFailureKind.Unreachable)]
    [InlineData(VerifyFailureKind.AuthFailed)]
    [InlineData(VerifyFailureKind.UntrustedCertificate)]
    [InlineData(VerifyFailureKind.Timeout)]
    [InlineData(VerifyFailureKind.ServerError)]
    public void VerifyFailureKind_RoundTrips(VerifyFailureKind value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(StoreAndForwardCategory.ExternalSystem)]
    [InlineData(StoreAndForwardCategory.Notification)]
    [InlineData(StoreAndForwardCategory.CachedDbWrite)]
    public void StoreAndForwardCategory_RoundTrips(StoreAndForwardCategory value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(AlarmState.Active)]
    [InlineData(AlarmState.Normal)]
    public void AlarmState_RoundTrips(AlarmState value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(AlarmLevel.None)]
    [InlineData(AlarmLevel.Low)]
    [InlineData(AlarmLevel.LowLow)]
    [InlineData(AlarmLevel.High)]
    [InlineData(AlarmLevel.HighHigh)]
    public void AlarmLevel_RoundTrips(AlarmLevel value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(AlarmKind.Computed)]
    [InlineData(AlarmKind.NativeOpcUa)]
    [InlineData(AlarmKind.NativeMxAccess)]
    public void AlarmKind_RoundTrips(AlarmKind value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    [Theory]
    [InlineData(AlarmShelveState.Unshelved)]
    [InlineData(AlarmShelveState.OneShotShelved)]
    [InlineData(AlarmShelveState.TimedShelved)]
    [InlineData(AlarmShelveState.PermanentShelved)]
    public void AlarmShelveState_RoundTrips(AlarmShelveState value) =>
        Assert.Equal(value, SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(value)));

    /// <summary>
    /// An unspecified wire enum — what a peer on an older contract sends — must
    /// decode to the documented safe default instead of faulting the command.
    /// </summary>
    [Fact]
    public void UnspecifiedWireEnums_DecodeToSafeDefaults()
    {
        Assert.Equal(DeploymentStatus.Failed, SiteCommandDtoMapper.FromProto(DeploymentStatusDto.Unspecified));
        Assert.Equal(BrowseNodeClass.Other, SiteCommandDtoMapper.FromProto(BrowseNodeClassDto.Unspecified));
        Assert.Equal(BrowseFailureKind.ServerError, SiteCommandDtoMapper.FromProto(BrowseFailureKindDto.Unspecified));
        Assert.Equal(
            ReadTagValuesFailureKind.ServerError,
            SiteCommandDtoMapper.FromProto(ReadTagValuesFailureKindDto.Unspecified));
        Assert.Equal(VerifyFailureKind.ServerError, SiteCommandDtoMapper.FromProto(VerifyFailureKindDto.Unspecified));
        Assert.Equal(
            StoreAndForwardCategory.ExternalSystem,
            SiteCommandDtoMapper.FromProto(StoreAndForwardCategoryDto.Unspecified));
        Assert.Equal(AlarmState.Normal, SiteCommandDtoMapper.FromProto(AlarmStateDto.Unspecified));
        Assert.Equal(AlarmLevel.None, SiteCommandDtoMapper.FromProto(AlarmLevelDto.Unspecified));
        Assert.Equal(AlarmKind.Computed, SiteCommandDtoMapper.FromProto(AlarmKindDto.Unspecified));
        Assert.Equal(AlarmShelveState.Unshelved, SiteCommandDtoMapper.FromProto(AlarmShelveStateDto.Unspecified));
    }

    // ─────────────────────────────────────────────────────────────────────
    // The two deliberate normalisations, asserted rather than hidden
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>
    /// Nullable strings ride as plain proto3 strings, so an empty one comes back
    /// as null. Documented in the mapper; asserted here so it stays a choice.
    /// </summary>
    [Fact]
    public void EmptyNullableString_NormalisesToNull()
    {
        var original = new SiteFailoverAck("corr", Accepted: false, TargetAddress: "", ErrorMessage: "");

        var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));

        Assert.Null(restored.TargetAddress);
        Assert.Null(restored.ErrorMessage);
    }

    /// <summary>
    /// …but NOT for a wait target, where the empty string is a real value. This
    /// is why that one field carries a <c>StringValue</c> wrapper.
    /// </summary>
    [Fact]
    public void EmptyWaitTarget_StaysDistinctFromNull()
    {
        var empty = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(
            new RouteToWaitForAttributeRequest("c", "i", "a", "", TimeSpan.FromSeconds(1), DateTimeOffset.UtcNow)));
        var missing = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(
            new RouteToWaitForAttributeRequest("c", "i", "a", null, TimeSpan.FromSeconds(1), DateTimeOffset.UtcNow)));

        Assert.Equal(string.Empty, empty.TargetValueEncoded);
        Assert.Null(missing.TargetValueEncoded);
    }

    /// <summary>
    /// A computed alarm leaves <see cref="AlarmStateChanged.Condition"/> implicit
    /// (the record derives it from State + Priority). The encoder omits a
    /// condition that already equals that derived value, so the record comes back
    /// byte-for-byte equal — including under record equality, which compares the
    /// nullable backing field, not the property.
    /// </summary>
    [Fact]
    public void ComputedAlarm_KeepsItsImplicitCondition()
    {
        var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow);

        var dto = SiteCommandDtoMapper.ToProto(original);
        var restored = SiteCommandDtoMapper.FromProto(dto);

        Assert.Null(dto.Condition);
        Assert.Equal(original, restored);
    }

    /// <summary>
    /// The flip side of the normalisation: a condition set EXPLICITLY to the value
    /// the record would have derived comes back implicit. The
    /// <see cref="AlarmStateChanged.Condition"/> value is identical — only the
    /// record's private "was it set?" bit differs, which no consumer can observe.
    /// </summary>
    [Fact]
    public void ExplicitConditionEqualToTheDerivedDefault_NormalisesToImplicit()
    {
        var original = new AlarmStateChanged("inst", "alarm", AlarmState.Normal, 100, DateTimeOffset.UtcNow)
        {
            Condition = AlarmConditionStateFactory.ForComputed(AlarmState.Normal, 100)
        };

        var restored = SiteCommandDtoMapper.FromProto(SiteCommandDtoMapper.ToProto(original));

        Assert.Equal(original.Condition, restored.Condition);
    }

    /// <summary>A native alarm's explicit, non-derived condition is carried verbatim.</summary>
    [Fact]
    public void NativeAlarm_KeepsItsExplicitCondition()
    {
        var condition = new AlarmConditionState(true, false, true, AlarmShelveState.PermanentShelved, true, 999);
        var original = new AlarmStateChanged("inst", "alarm", AlarmState.Active, 500, DateTimeOffset.UtcNow)
        {
            Kind = AlarmKind.NativeMxAccess,
            Condition = condition
        };

        var dto = SiteCommandDtoMapper.ToProto(original);
        var restored = SiteCommandDtoMapper.FromProto(dto);

        Assert.NotNull(dto.Condition);
        Assert.Equal(condition, restored.Condition);
    }

    // ─────────────────────────────────────────────────────────────────────
    // Group classification + rejection
    // ─────────────────────────────────────────────────────────────────────

    /// <summary>Packing a command into the wrong group's envelope is a hard error, not a silent no-op.</summary>
    [Fact]
    public void PackingIntoTheWrongGroup_Throws() =>
        Assert.Throws<ArgumentException>(() =>
            SiteCommandDtoMapper.ToLifecycleRequest(new DebugSnapshotRequest("inst", "corr")));

    /// <summary>An envelope with no oneof set (a newer peer's unknown case) surfaces as a clear failure.</summary>
    [Fact]
    public void UnsetOneof_ThrowsNotSupported() =>
        Assert.Throws<NotSupportedException>(() => SiteCommandDtoMapper.FromLifecycleRequest(new LifecycleRequest()));

    // ─────────────────────────────────────────────────────────────────────
    // Reflection plumbing
    // ─────────────────────────────────────────────────────────────────────

    private static object RoundTrip(object original, Type type)
    {
        var toProto = typeof(SiteCommandDtoMapper)
            .GetMethods(BindingFlags.Public | BindingFlags.Static)
            .Single(m => m.Name == "ToProto" && m.GetParameters() is [{ } p] && p.ParameterType == type);

        var wire = toProto.Invoke(null, [original])!;

        var fromProto = typeof(SiteCommandDtoMapper)
            .GetMethods(BindingFlags.Public | BindingFlags.Static)
            .Single(m => m.Name == "FromProto"
                         && m.GetParameters() is [{ } p]
                         && p.ParameterType == wire.GetType());

        return fromProto.Invoke(null, [wire])!;
    }

    private static IEnumerable<Type> MappedRecordTypes() =>
        typeof(SiteCommandDtoMapper)
            .GetMethods(BindingFlags.Public | BindingFlags.Static)
            .Where(m => m.Name == "ToProto" && m.GetParameters().Length == 1)
            .Select(m => m.GetParameters()[0].ParameterType)
            .Where(t => !t.IsEnum)
            .Distinct();

    private static IEnumerable<Type> CommandSampleTypes() =>
        SiteCommandSamples.All.Keys.Where(IsCommand);

    /// <summary>
    /// Reply shapes = the mapper's own classification, plus the synthetic
    /// unsubscribe ack, which has no <c>ToProto</c> overload of its own.
    /// </summary>
    private static IEnumerable<Type> ReplyTypes() =>
        SiteCommandSamples.All.Keys.Where(IsReply).Append(typeof(UnsubscribeDebugViewAck));

    private static bool IsCommand(Type type) => Classifies(type, SiteCommandDtoMapper.GroupOf);

    private static bool IsReply(Type type) => Classifies(type, SiteCommandDtoMapper.GroupOfReply);

    private static bool Classifies(Type type, Func<object, SiteCommandGroup> classify)
    {
        try
        {
            classify(SiteCommandSamples.All[type][0]);
            return true;
        }
        catch (ArgumentException)
        {
            return false;
        }
    }

    private static object Sample(Type type) => SiteCommandSamples.All[type][0];

    private static object SampleReply(Type type) =>
        type == typeof(UnsubscribeDebugViewAck) ? UnsubscribeDebugViewAck.Instance : Sample(type);

    private static object PackCommand(object command) => SiteCommandDtoMapper.GroupOf(command) switch
    {
        SiteCommandGroup.Lifecycle => SiteCommandDtoMapper.ToLifecycleRequest(command),
        SiteCommandGroup.OpcUa => SiteCommandDtoMapper.ToOpcUaRequest(command),
        SiteCommandGroup.Query => SiteCommandDtoMapper.ToQueryRequest(command),
        SiteCommandGroup.Parked => SiteCommandDtoMapper.ToParkedRequest(command),
        SiteCommandGroup.Route => SiteCommandDtoMapper.ToRouteRequest(command),
        _ => SiteCommandDtoMapper.ToProto((TriggerSiteFailover)command)
    };

    private static object UnpackCommand(object envelope) => envelope switch
    {
        LifecycleRequest r => SiteCommandDtoMapper.FromLifecycleRequest(r),
        OpcUaRequest r => SiteCommandDtoMapper.FromOpcUaRequest(r),
        QueryRequest r => SiteCommandDtoMapper.FromQueryRequest(r),
        ParkedRequest r => SiteCommandDtoMapper.FromParkedRequest(r),
        RouteRequest r => SiteCommandDtoMapper.FromRouteRequest(r),
        TriggerSiteFailoverDto d => SiteCommandDtoMapper.FromProto(d),
        _ => throw new InvalidOperationException($"Unknown request envelope {envelope.GetType().Name}.")
    };

    private static object PackReply(object reply) => SiteCommandDtoMapper.GroupOfReply(reply) switch
    {
        SiteCommandGroup.Lifecycle => SiteCommandDtoMapper.ToLifecycleReply(reply),
        SiteCommandGroup.OpcUa => SiteCommandDtoMapper.ToOpcUaReply(reply),
        SiteCommandGroup.Query => SiteCommandDtoMapper.ToQueryReply(reply),
        SiteCommandGroup.Parked => SiteCommandDtoMapper.ToParkedReply(reply),
        SiteCommandGroup.Route => SiteCommandDtoMapper.ToRouteReply(reply),
        _ => SiteCommandDtoMapper.ToProto((SiteFailoverAck)reply)
    };

    private static object UnpackReply(object envelope) => envelope switch
    {
        LifecycleReply r => SiteCommandDtoMapper.FromLifecycleReply(r),
        OpcUaReply r => SiteCommandDtoMapper.FromOpcUaReply(r),
        QueryReply r => SiteCommandDtoMapper.FromQueryReply(r),
        ParkedReply r => SiteCommandDtoMapper.FromParkedReply(r),
        RouteReply r => SiteCommandDtoMapper.FromRouteReply(r),
        SiteFailoverAckDto d => SiteCommandDtoMapper.FromProto(d),
        _ => throw new InvalidOperationException($"Unknown reply envelope {envelope.GetType().Name}.")
    };

    private static MessageDescriptor DescriptorFor(string name) =>
        SiteCommandReflection.Descriptor.MessageTypes.Single(m => m.Name == name);

    private static string OneofCaseName(IMessage envelope)
    {
        var oneof = envelope.Descriptor.Oneofs[0];
        var field = oneof.Accessor.GetCaseFieldDescriptor(envelope);
        Assert.NotNull(field);
        return field.PropertyName;
    }
}
