using System.Collections;
using System.Globalization;
using System.Text.Json;
using Google.Protobuf;

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

/// <summary>
/// Codec for the loosely-typed <c>object?</c> 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
/// <see cref="LooseValue"/> proto carrier.
/// </summary>
/// <remarks>
/// <para>
/// <b>Why a tagged union rather than a string or JSON blob.</b> These values
/// reach an operator's screen (Test Bindings, Debug View) and a device write
/// (<c>WriteTag</c>), so collapsing them to text would change behaviour: today
/// the Akka JSON serializer runs with <c>TypeNameHandling</c> 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.
/// </para>
/// <para>
/// <b>The one documented lossy path.</b> Anything outside the tagged set —
/// an exotic numeric (<see cref="byte"/>, <see cref="uint"/>, …), an enum, a
/// POCO — falls back to <see cref="LooseValue.JsonValue"/> and decodes as a
/// <see cref="JsonElement"/> 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
/// <see cref="LooseValueList"/>/<see cref="LooseValueMap"/>) and decode to
/// <c>List&lt;object?&gt;</c> / <c>Dictionary&lt;string, object?&gt;</c>, so
/// their ELEMENTS keep their types while the container type widens.
/// </para>
/// <para>
/// <b>Why dates ride as strings.</b> <c>google.protobuf.Timestamp</c>
/// normalises everything to UTC, which silently discards
/// <see cref="DateTime.Kind"/> and <see cref="DateTimeOffset.Offset"/>. For a
/// timestamped tag value that is data loss, not normalisation, so
/// <see cref="DateTime"/>/<see cref="DateTimeOffset"/>/<see cref="TimeSpan"/>
/// use invariant round-trip formats instead. (Message FIELDS that are declared
/// <c>DateTimeOffset</c> in the DTO are a different case and do use
/// <c>Timestamp</c> — see <see cref="SiteCommandDtoMapper"/>.)
/// </para>
/// </remarks>
public static class LooseValueCodec
{
    private static readonly JsonSerializerOptions JsonOpts = new() { WriteIndented = false };

    /// <summary>
    /// Encodes a boxed value for a NULLABLE message field: <c>null</c> returns
    /// <c>null</c> so the field is simply left unset on the wire.
    /// </summary>
    /// <param name="value">The boxed value to encode, or <c>null</c>.</param>
    /// <returns>The encoded carrier, or <c>null</c> when <paramref name="value"/> is <c>null</c>.</returns>
    public static LooseValue? ToProtoOrNull(object? value) =>
        value is null ? null : ToProto(value);

    /// <summary>
    /// Encodes a boxed value, representing <c>null</c> explicitly as
    /// <see cref="LooseNull"/>. Used inside maps and lists, where an absent
    /// entry means "no such key/element" rather than "a null value".
    /// </summary>
    /// <param name="value">The boxed value to encode, or <c>null</c>.</param>
    /// <returns>A populated <see cref="LooseValue"/>; never <c>null</c>.</returns>
    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) }
    };

    /// <summary>
    /// Decodes a carrier back to a boxed value. An unset field
    /// (<c>null</c>) and an explicit <see cref="LooseNull"/> both decode to
    /// <c>null</c>, so the two encoders above are interchangeable on read.
    /// </summary>
    /// <param name="value">The carrier to decode, or <c>null</c> when the field was unset.</param>
    /// <returns>The decoded boxed value; <c>null</c> for an unset or explicitly-null carrier.</returns>
    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<JsonElement>(value.JsonValue),
            _ => null
        };
    }

    /// <summary>
    /// Encodes a string-keyed dictionary onto the wire. Null values are carried
    /// explicitly (<see cref="LooseNull"/>), so a key present with a null value
    /// stays distinct from an absent key.
    /// </summary>
    /// <param name="values">The dictionary to encode.</param>
    /// <returns>A populated <see cref="LooseValueMap"/>.</returns>
    public static LooseValueMap ToProtoMap(IReadOnlyDictionary<string, object?> values)
    {
        ArgumentNullException.ThrowIfNull(values);

        var map = new LooseValueMap();
        foreach (var (key, value) in values)
        {
            map.Entries[key] = ToProto(value);
        }

        return map;
    }

    /// <summary>
    /// Encodes a NULLABLE string-keyed dictionary: <c>null</c> returns
    /// <c>null</c> so the field is left unset and the null/empty distinction
    /// survives (proto3 <c>map</c> alone cannot express it).
    /// </summary>
    /// <param name="values">The dictionary to encode, or <c>null</c>.</param>
    /// <returns>The encoded map, or <c>null</c> when <paramref name="values"/> is <c>null</c>.</returns>
    public static LooseValueMap? ToProtoMapOrNull(IReadOnlyDictionary<string, object?>? values) =>
        values is null ? null : ToProtoMap(values);

    /// <summary>Decodes a wire map back to a dictionary; an unset field decodes to <c>null</c>.</summary>
    /// <param name="map">The wire map, or <c>null</c> when the field was unset.</param>
    /// <returns>The decoded dictionary, or <c>null</c>.</returns>
    public static IReadOnlyDictionary<string, object?>? FromProtoMapOrNull(LooseValueMap? map) =>
        map is null ? null : FromMap(map);

    /// <summary>Decodes a wire map back to a dictionary; an unset field decodes to an EMPTY dictionary.</summary>
    /// <remarks>For DTO members that are declared non-nullable, so "absent" can only mean "empty".</remarks>
    /// <param name="map">The wire map, or <c>null</c> when the field was unset.</param>
    /// <returns>The decoded dictionary; empty when <paramref name="map"/> is <c>null</c>.</returns>
    public static IReadOnlyDictionary<string, object?> FromProtoMap(LooseValueMap? map) =>
        map is null ? new Dictionary<string, object?>() : 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<object?> FromList(LooseValueList list)
    {
        var result = new List<object?>(list.Items.Count);
        foreach (var item in list.Items)
        {
            result.Add(FromProto(item));
        }

        return result;
    }

    private static Dictionary<string, object?> FromMap(LooseValueMap map)
    {
        var result = new Dictionary<string, object?>(map.Entries.Count);
        foreach (var (key, value) in map.Entries)
        {
            result[key] = FromProto(value);
        }

        return result;
    }
}
