using System.Text.Json;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;

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

/// <summary>
/// Goldens for <see cref="LooseValueCodec"/> — the type-tagged carrier for the
/// <c>object?</c> members of the site command plane (script parameters and
/// return values, attribute values, tag reads/writes).
/// </summary>
/// <remarks>
/// The contract these tests pin down is that a boxed value keeps its RUNTIME
/// CLR TYPE across the wire, not merely its printed form. Today's Akka JSON
/// serializer preserves it, and an operator-facing tag value that silently
/// turns from <c>int</c> into <c>long</c> (or from <c>DateTime</c> into a
/// UTC-normalised copy) is a behaviour change, not a refactor.
/// </remarks>
public class LooseValueCodecTests
{
    public static IEnumerable<object[]> TaggedScalars() =>
    [
        [true],
        [false],
        [42],
        [-1],
        [long.MaxValue],
        [3.5d],
        [1.5f],
        ["a string"],
        [string.Empty],
        [12.3456789m],
        [new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Utc)],
        [new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(-5))],
        [Guid.Parse("11111111-2222-3333-4444-555555555555")],
        [TimeSpan.FromMinutes(90)],
        [new byte[] { 1, 2, 3 }]
    ];

    [Theory]
    [MemberData(nameof(TaggedScalars))]
    public void TaggedValues_KeepTheirClrType(object value)
    {
        var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(value));

        Assert.NotNull(restored);
        Assert.Equal(value.GetType(), restored.GetType());
        StructuralEquality.AssertDeepEqual(value, restored, value.GetType().Name);
    }

    /// <summary>
    /// A local <see cref="DateTime"/> must come back local, and an offset
    /// <see cref="DateTimeOffset"/> must come back with the same offset — the
    /// reason these ride as round-trip strings instead of proto timestamps.
    /// </summary>
    [Fact]
    public void DateValues_KeepKindAndOffset()
    {
        var local = new DateTime(2026, 7, 22, 1, 2, 3, DateTimeKind.Local);
        var offset = new DateTimeOffset(2026, 7, 22, 1, 2, 3, TimeSpan.FromHours(5.5));

        var restoredLocal = Assert.IsType<DateTime>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(local)));
        var restoredOffset =
            Assert.IsType<DateTimeOffset>(LooseValueCodec.FromProto(LooseValueCodec.ToProto(offset)));

        Assert.Equal(DateTimeKind.Local, restoredLocal.Kind);
        Assert.Equal(local, restoredLocal);
        Assert.Equal(TimeSpan.FromHours(5.5), restoredOffset.Offset);
    }

    /// <summary>An unset carrier and an explicit null marker both decode to null.</summary>
    [Fact]
    public void NullIsCarriedTwoWays()
    {
        Assert.Null(LooseValueCodec.FromProto(null));
        Assert.Null(LooseValueCodec.FromProto(LooseValueCodec.ToProto(null)));
        Assert.Null(LooseValueCodec.ToProtoOrNull(null));
    }

    /// <summary>
    /// A dictionary entry whose value is null stays distinct from an absent key —
    /// the reason <c>LooseNull</c> exists at all.
    /// </summary>
    [Fact]
    public void NullMapValue_SurvivesAsAPresentKey()
    {
        var original = new Dictionary<string, object?> { ["present"] = null };

        var restored = LooseValueCodec.FromProtoMap(LooseValueCodec.ToProtoMap(original));

        Assert.True(restored.ContainsKey("present"));
        Assert.Null(restored["present"]);
    }

    /// <summary>A null dictionary stays null; an empty one stays empty.</summary>
    [Fact]
    public void NullMap_StaysDistinctFromEmptyMap()
    {
        Assert.Null(LooseValueCodec.ToProtoMapOrNull(null));
        Assert.Null(LooseValueCodec.FromProtoMapOrNull(null));

        var empty = LooseValueCodec.FromProtoMapOrNull(
            LooseValueCodec.ToProtoMapOrNull(new Dictionary<string, object?>()));

        Assert.NotNull(empty);
        Assert.Empty(empty);
    }

    /// <summary>Nested lists and maps recurse, so element values keep their types too.</summary>
    [Fact]
    public void NestedCollections_KeepElementTypes()
    {
        object original = new List<object?>
        {
            1,
            "two",
            null,
            new Dictionary<string, object?> { ["deep"] = 3.5d }
        };

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

        StructuralEquality.AssertDeepEqual(original, restored, "list");
    }

    /// <summary>
    /// The documented widening: a typed collection keeps its ELEMENTS' types but
    /// the container comes back as <c>List&lt;object?&gt;</c>. Recorded here so
    /// the behaviour is a decision rather than a surprise.
    /// </summary>
    [Fact]
    public void TypedList_WidensToObjectList()
    {
        var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto(new List<int> { 1, 2, 3 }));

        var list = Assert.IsType<List<object?>>(restored);
        Assert.Equal([1, 2, 3], list.Cast<int>());
    }

    /// <summary>
    /// The documented lossy escape hatch: a value outside the tagged set keeps its
    /// DATA but not its CLR identity — it decodes as a <see cref="JsonElement"/>.
    /// Nothing on the command plane carries such a value today; the fallback
    /// exists so an unexpected one cannot fault a command.
    /// </summary>
    [Fact]
    public void UntaggedValue_FallsBackToJsonAndLosesItsClrType()
    {
        var restored = LooseValueCodec.FromProto(LooseValueCodec.ToProto((byte)7));

        var element = Assert.IsType<JsonElement>(restored);
        Assert.Equal("7", element.GetRawText());
    }
}
