using System.Linq;
using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Server.OpcUa;

namespace ZB.MOM.WW.OtOpcUa.Server.Tests;

/// <summary>
///     Unit coverage for the static helpers <see cref="DriverNodeManager"/> exposes to bridge
///     driver-side history data (<see cref="HistoricalEvent"/> + <see cref="DataValueSnapshot"/>)
///     to the OPC UA on-wire shape (<c>HistoryData</c> / <c>HistoryEvent</c> wrapped in an
///     <see cref="ExtensionObject"/>). Fast, framework-only — no server fixture.
/// </summary>
[Trait("Category", "Unit")]
public sealed class DriverNodeManagerHistoryMappingTests
{
    [Theory]
    [InlineData(nameof(HistoryAggregateType.Average), HistoryAggregateType.Average)]
    [InlineData(nameof(HistoryAggregateType.Minimum), HistoryAggregateType.Minimum)]
    [InlineData(nameof(HistoryAggregateType.Maximum), HistoryAggregateType.Maximum)]
    [InlineData(nameof(HistoryAggregateType.Total), HistoryAggregateType.Total)]
    [InlineData(nameof(HistoryAggregateType.Count), HistoryAggregateType.Count)]
    public void MapAggregate_translates_each_supported_OPC_UA_aggregate_NodeId(
        string name, HistoryAggregateType expected)
    {
        // Resolve the ObjectIds.AggregateFunction_<name> constant via reflection so the test
        // keeps working if the stack ever renames them — failure means the stack broke its
        // naming convention, worth surfacing loudly.
        var field = typeof(ObjectIds).GetField("AggregateFunction_" + name);
        field.ShouldNotBeNull();
        var nodeId = (NodeId)field!.GetValue(null)!;

        DriverNodeManager.MapAggregate(nodeId).ShouldBe(expected);
    }

    [Fact]
    public void MapAggregate_returns_null_for_unknown_aggregate()
    {
        // AggregateFunction_TimeAverage is a valid OPC UA aggregate but not one the driver
        // surfaces. Null here means the service handler will translate to BadAggregateNotSupported
        // — the right behavior per Part 13 when the requested aggregate isn't implemented.
        DriverNodeManager.MapAggregate(ObjectIds.AggregateFunction_TimeAverage).ShouldBeNull();
    }

    [Fact]
    public void MapAggregate_returns_null_for_null_input()
    {
        // Processed requests that omit the aggregate list (or pass a single null) must not crash.
        DriverNodeManager.MapAggregate(null).ShouldBeNull();
    }

    [Fact]
    public void BuildHistoryData_wraps_samples_as_HistoryData_extension_object()
    {
        var samples = new[]
        {
            new DataValueSnapshot(Value: 42, StatusCode: StatusCodes.Good,
                SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
                ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 1, DateTimeKind.Utc)),
            new DataValueSnapshot(Value: 99, StatusCode: StatusCodes.Good,
                SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 5, DateTimeKind.Utc),
                ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 6, DateTimeKind.Utc)),
        };

        var ext = DriverNodeManager.BuildHistoryData(samples);

        ext.Body.ShouldBeOfType<HistoryData>();
        var hd = (HistoryData)ext.Body;
        hd.DataValues.Count.ShouldBe(2);
        hd.DataValues[0].Value.ShouldBe(42);
        hd.DataValues[1].Value.ShouldBe(99);
        hd.DataValues[0].SourceTimestamp.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc));
    }

    [Fact]
    public void BuildHistoryEvent_wraps_events_with_BaseEventType_field_ordering()
    {
        // BuildHistoryEvent populates a fixed field set in BaseEventType's conventional order:
        // EventId, SourceName, Message, Severity, Time, ReceiveTime. Pinning this so a later
        // "respect the client's SelectClauses" change can't silently break older clients that
        // rely on the default layout.
        var events = new[]
        {
            new HistoricalEvent(
                EventId: "e-1",
                SourceName: "Tank1.HiAlarm",
                EventTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
                ReceivedTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc),
                Message: "High level reached",
                Severity: 750),
        };

        var ext = DriverNodeManager.BuildHistoryEvent(events);

        ext.Body.ShouldBeOfType<HistoryEvent>();
        var he = (HistoryEvent)ext.Body;
        he.Events.Count.ShouldBe(1);
        var fields = he.Events[0].EventFields;
        fields.Count.ShouldBe(6);
        fields[0].Value.ShouldBe("e-1");                    // EventId
        fields[1].Value.ShouldBe("Tank1.HiAlarm");          // SourceName
        ((LocalizedText)fields[2].Value).Text.ShouldBe("High level reached"); // Message
        fields[3].Value.ShouldBe((ushort)750);              // Severity
        ((DateTime)fields[4].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc));
        ((DateTime)fields[5].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc));
    }

    [Fact]
    public void BuildHistoryEvent_substitutes_empty_string_for_null_SourceName_and_Message()
    {
        // Driver-side nulls are preserved through the wire contract by design (distinguishes
        // "system event with no source" from "source unknown"), but OPC UA Variants of type
        // String must not carry null — the stack serializes null-string as empty. This test
        // pins the choice so a nullable-Variant refactor doesn't break clients that display
        // the field without a null check.
        var events = new[]
        {
            new HistoricalEvent("sys", null, DateTime.UtcNow, DateTime.UtcNow, null, 1),
        };

        var ext = DriverNodeManager.BuildHistoryEvent(events);
        var fields = ((HistoryEvent)ext.Body).Events[0].EventFields;
        fields[1].Value.ShouldBe(string.Empty);
        ((LocalizedText)fields[2].Value).Text.ShouldBe(string.Empty);
    }

    [Fact]
    public void ToDataValue_preserves_status_code_and_timestamps()
    {
        var snap = new DataValueSnapshot(
            Value: 123.45,
            StatusCode: StatusCodes.UncertainSubstituteValue,
            SourceTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc),
            ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));

        var dv = DriverNodeManager.ToDataValue(snap);

        dv.Value.ShouldBe(123.45);
        dv.StatusCode.Code.ShouldBe(StatusCodes.UncertainSubstituteValue);
        dv.SourceTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc));
        dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));
    }

    [Fact]
    public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time()
    {
        // Galaxy's raw-history rows often carry only a ServerTimestamp (the historian knows
        // when it wrote the row, not when the process sampled it). The mapping must not
        // synthesize a bogus SourceTimestamp from ServerTimestamp — that would lie to the
        // client about the measurement's actual time.
        var snap = new DataValueSnapshot(Value: 1, StatusCode: 0,
            SourceTimestampUtc: null,
            ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc));

        var dv = DriverNodeManager.ToDataValue(snap);
        dv.SourceTimestamp.ShouldBe(default);
    }
}
