using System.Collections;
using System.Globalization;
using System.Text.Json;
using Google.Protobuf;
namespace ZB.MOM.WW.ScadaBridge.Communication.Grpc;
///
/// Codec for the loosely-typed object? members that survive on the site
/// command plane — script parameters and return values, attribute values, and
/// OPC UA tag read/write values — mapping them to and from the type-tagged
/// proto carrier.
///
///
///
/// Why a tagged union rather than a string or JSON blob. These values
/// reach an operator's screen (Test Bindings, Debug View) and a device write
/// (WriteTag), so collapsing them to text would change behaviour: today
/// the Akka JSON serializer runs with TypeNameHandling on and preserves
/// the boxed CLR type end-to-end. The tags below cover every CLR type these
/// fields actually carry, so those values keep their runtime type across the
/// wire exactly as they do over Akka remoting.
///
///
/// The one documented lossy path. Anything outside the tagged set —
/// an exotic numeric (, , …), an enum, a
/// POCO — falls back to and decodes as a
/// rather than its original CLR type. The value
/// itself is preserved; its CLR identity is not. Collections and string-keyed
/// dictionaries do NOT take that path: they encode recursively (see
/// /) and decode to
/// List<object?> / Dictionary<string, object?>, so
/// their ELEMENTS keep their types while the container type widens.
///
///
/// Why dates ride as strings.google.protobuf.Timestamp
/// normalises everything to UTC, which silently discards
/// and . For a
/// timestamped tag value that is data loss, not normalisation, so
/// //
/// use invariant round-trip formats instead. (Message FIELDS that are declared
/// DateTimeOffset in the DTO are a different case and do use
/// Timestamp — see .)
///
///
public static class LooseValueCodec
{
private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };
///
/// Encodes a boxed value for a NULLABLE message field: null returns
/// null so the field is simply left unset on the wire.
///
/// The boxed value to encode, or null.
/// The encoded carrier, or null when is null.
public static LooseValue? ToProtoOrNull(object? value) =>
value is null ? null : ToProto(value);
///
/// Encodes a boxed value, representing null explicitly as
/// . Used inside maps and lists, where an absent
/// entry means "no such key/element" rather than "a null value".
///
/// The boxed value to encode, or null.
/// A populated ; never null.
public static LooseValue ToProto(object? value) => value switch
{
null => new LooseValue { NullValue = new LooseNull() },
bool b => new LooseValue { BoolValue = b },
int i => new LooseValue { Int32Value = i },
long l => new LooseValue { Int64Value = l },
double d => new LooseValue { DoubleValue = d },
float f => new LooseValue { FloatValue = f },
string s => new LooseValue { StringValue = s },
decimal m => new LooseValue { DecimalValue = m.ToString(CultureInfo.InvariantCulture) },
DateTime dt => new LooseValue { DateTimeValue = dt.ToString("O", CultureInfo.InvariantCulture) },
DateTimeOffset dto => new LooseValue { DateTimeOffsetValue = dto.ToString("O", CultureInfo.InvariantCulture) },
Guid g => new LooseValue { GuidValue = g.ToString("D") },
TimeSpan ts => new LooseValue { TimeSpanValue = ts.ToString("c", CultureInfo.InvariantCulture) },
byte[] bytes => new LooseValue { BytesValue = ByteString.CopyFrom(bytes) },
IDictionary dict => new LooseValue { MapValue = ToMap(dict) },
IEnumerable seq => new LooseValue { ListValue = ToList(seq) },
// Escape hatch. Preserves the value, not the CLR type — see the remarks.
_ => new LooseValue { JsonValue = JsonSerializer.Serialize(value, value.GetType(), JsonOpts) }
};
///
/// Decodes a carrier back to a boxed value. An unset field
/// (null) and an explicit both decode to
/// null, so the two encoders above are interchangeable on read.
///
/// The carrier to decode, or null when the field was unset.
/// The decoded boxed value; null for an unset or explicitly-null carrier.
public static object? FromProto(LooseValue? value)
{
if (value is null)
{
return null;
}
return value.KindCase switch
{
LooseValue.KindOneofCase.None => null,
LooseValue.KindOneofCase.NullValue => null,
LooseValue.KindOneofCase.BoolValue => value.BoolValue,
LooseValue.KindOneofCase.Int32Value => value.Int32Value,
LooseValue.KindOneofCase.Int64Value => value.Int64Value,
LooseValue.KindOneofCase.DoubleValue => value.DoubleValue,
LooseValue.KindOneofCase.FloatValue => value.FloatValue,
LooseValue.KindOneofCase.StringValue => value.StringValue,
LooseValue.KindOneofCase.DecimalValue =>
decimal.Parse(value.DecimalValue, CultureInfo.InvariantCulture),
LooseValue.KindOneofCase.DateTimeValue =>
DateTime.Parse(value.DateTimeValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
LooseValue.KindOneofCase.DateTimeOffsetValue =>
DateTimeOffset.Parse(value.DateTimeOffsetValue, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind),
LooseValue.KindOneofCase.GuidValue => Guid.Parse(value.GuidValue),
LooseValue.KindOneofCase.TimeSpanValue =>
TimeSpan.ParseExact(value.TimeSpanValue, "c", CultureInfo.InvariantCulture),
LooseValue.KindOneofCase.BytesValue => value.BytesValue.ToByteArray(),
LooseValue.KindOneofCase.ListValue => FromList(value.ListValue),
LooseValue.KindOneofCase.MapValue => FromMap(value.MapValue),
LooseValue.KindOneofCase.JsonValue => JsonSerializer.Deserialize(value.JsonValue),
_ => null
};
}
///
/// Encodes a string-keyed dictionary onto the wire. Null values are carried
/// explicitly (), so a key present with a null value
/// stays distinct from an absent key.
///
/// The dictionary to encode.
/// A populated .
public static LooseValueMap ToProtoMap(IReadOnlyDictionary values)
{
ArgumentNullException.ThrowIfNull(values);
var map = new LooseValueMap();
foreach (var (key, value) in values)
{
map.Entries[key] = ToProto(value);
}
return map;
}
///
/// Encodes a NULLABLE string-keyed dictionary: null returns
/// null so the field is left unset and the null/empty distinction
/// survives (proto3 map alone cannot express it).
///
/// The dictionary to encode, or null.
/// The encoded map, or null when is null.
public static LooseValueMap? ToProtoMapOrNull(IReadOnlyDictionary? values) =>
values is null ? null : ToProtoMap(values);
/// Decodes a wire map back to a dictionary; an unset field decodes to null.
/// The wire map, or null when the field was unset.
/// The decoded dictionary, or null.
public static IReadOnlyDictionary? FromProtoMapOrNull(LooseValueMap? map) =>
map is null ? null : FromMap(map);
/// Decodes a wire map back to a dictionary; an unset field decodes to an EMPTY dictionary.
/// For DTO members that are declared non-nullable, so "absent" can only mean "empty".
/// The wire map, or null when the field was unset.
/// The decoded dictionary; empty when is null.
public static IReadOnlyDictionary FromProtoMap(LooseValueMap? map) =>
map is null ? new Dictionary() : FromMap(map);
private static LooseValueList ToList(IEnumerable source)
{
var list = new LooseValueList();
foreach (var element in source)
{
list.Items.Add(ToProto(element));
}
return list;
}
private static LooseValueMap ToMap(IDictionary source)
{
var map = new LooseValueMap();
foreach (DictionaryEntry entry in source)
{
// Non-string keys are outside the wire contract; the invariant-culture
// rendering keeps the entry readable rather than dropping it silently.
var key = entry.Key as string
?? Convert.ToString(entry.Key, CultureInfo.InvariantCulture)
?? string.Empty;
map.Entries[key] = ToProto(entry.Value);
}
return map;
}
private static List