using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.DataConnectionLayer.Adapters;

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

/// <summary>Task-11: pure OPC UA A&C field → AlarmConditionState/transition mapping.</summary>
public class OpcUaAlarmMapperTests
{
    // ── Gitea #17: routing identity must be the binding, not the event name ──

    [Fact]
    public void BuildIdentity_NodeIdBinding_RoutesOnBindingNotName()
    {
        // OtOpcUa v3 post-#473: SourceName is the RawPath, ConditionName the leaf. The
        // binding is a durable node reference. The bug was SourceObjectReference = SourceName,
        // which never prefix-matches a NodeId binding, so every transition was dropped.
        var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
            subscriptionSourceReference: "nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200",
            sourceName: "pymodbus/plc/HR200",
            conditionName: "HR200");

        // Routing identity == the binding, verbatim → DataConnectionActor exact-matches.
        Assert.Equal("nsu=https://zb.com/otopcua/raw;s=pymodbus/plc/HR200", sourceObjectRef);
        // Per-condition key stays the readable RawPath + condition, kept distinct.
        Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef);
    }

    [Fact]
    public void BuildIdentity_LegacyIndexBinding_RoutesVerbatim()
    {
        // A binding stored in the old ns= form routes just as well — the point of using
        // the binding verbatim is that it is form-agnostic.
        var (_, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
            subscriptionSourceReference: "ns=2;s=pymodbus/plc/HR200",
            sourceName: "pymodbus/plc/HR200",
            conditionName: "HR200");

        Assert.Equal("ns=2;s=pymodbus/plc/HR200", sourceObjectRef);
    }

    [Fact]
    public void BuildIdentity_AggregateFeed_StaysEmpty()
    {
        // Empty binding = the Server-object aggregate feed: it must reach only "mirror
        // everything" subscribers (StartsWith("") matches all) and never a specific-node
        // binding, so the routing identity stays empty while the per-condition key is real.
        var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
            subscriptionSourceReference: "",
            sourceName: "pymodbus/plc/HR200",
            conditionName: "HR200");

        Assert.Equal("", sourceObjectRef);
        Assert.Equal("pymodbus/plc/HR200.HR200", sourceRef);
    }

    [Fact]
    public void BuildIdentity_NoConditionName_UsesSourceNameAlone()
    {
        var (sourceRef, _) = OpcUaAlarmMapper.BuildIdentity(
            subscriptionSourceReference: "nsu=urn:x;s=Tank01",
            sourceName: "Tank01",
            conditionName: null);

        Assert.Equal("Tank01", sourceRef);
    }

    [Fact]
    public void BuildIdentity_NoSourceName_FallsBackToBindingForKey()
    {
        // A server that leaves SourceName unset still yields a stable, non-empty key.
        var (sourceRef, sourceObjectRef) = OpcUaAlarmMapper.BuildIdentity(
            subscriptionSourceReference: "ns=2;s=X",
            sourceName: null,
            conditionName: "Hi");

        Assert.Equal("ns=2;s=X.Hi", sourceRef);
        Assert.Equal("ns=2;s=X", sourceObjectRef);
    }

    [Fact]
    public void NormalizeSeverity_ClampsTo0_1000()
    {
        Assert.Equal(1000, OpcUaAlarmMapper.NormalizeSeverity(5000));
        Assert.Equal(0, OpcUaAlarmMapper.NormalizeSeverity(-1));
        Assert.Equal(500, OpcUaAlarmMapper.NormalizeSeverity(500));
    }

    [Fact]
    public void BuildCondition_ActiveUnacked()
    {
        var c = OpcUaAlarmMapper.BuildCondition(active: true, acked: false, confirmed: null,
            shelve: AlarmShelveState.Unshelved, suppressed: false, severity: 700);
        Assert.True(c.Active);
        Assert.False(c.Acknowledged);
        Assert.Equal(700, c.Severity);
        Assert.Equal(AlarmShelveState.Unshelved, c.Shelve);
    }

    [Fact]
    public void DeriveKind_AckEdge_YieldsAcknowledge()
    {
        Assert.Equal(AlarmTransitionKind.Acknowledge,
            OpcUaAlarmMapper.DeriveKind(prevAcked: false, nowAcked: true, prevActive: true, nowActive: true));
    }

    [Fact]
    public void DeriveKind_ActiveEdge_YieldsRaise()
    {
        Assert.Equal(AlarmTransitionKind.Raise,
            OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: false, nowActive: true));
    }

    [Fact]
    public void DeriveKind_InactiveEdge_YieldsClear()
    {
        Assert.Equal(AlarmTransitionKind.Clear,
            OpcUaAlarmMapper.DeriveKind(prevAcked: true, nowAcked: true, prevActive: true, nowActive: false));
    }

    [Theory]
    [InlineData("OneShotShelved", AlarmShelveState.OneShotShelved)]
    [InlineData("TimedShelved", AlarmShelveState.TimedShelved)]
    [InlineData("Unshelved", AlarmShelveState.Unshelved)]
    [InlineData(null, AlarmShelveState.Unshelved)]
    public void MapShelve_MapsCurrentStateName(string? name, AlarmShelveState expected)
    {
        Assert.Equal(expected, OpcUaAlarmMapper.MapShelve(name));
    }

    // ── PickLimitValue (M2.13 / #27) ─────────────────────────────────────────

    [Fact]
    public void PickLimitValue_AllNull_ReturnsEmpty()
    {
        // All four limit fields absent (non-limit alarm type) → empty string.
        Assert.Equal("", OpcUaAlarmMapper.PickLimitValue(null, null, null, null));
    }

    [Fact]
    public void PickLimitValue_HighHighLimitPresent_ReturnsIt()
    {
        // HighHighLimit takes top priority; other fields are null (absent).
        var result = OpcUaAlarmMapper.PickLimitValue(100.5, null, null, null);
        Assert.Equal("100.5", result);
    }

    [Fact]
    public void PickLimitValue_OnlyHighLimit_ReturnsHighLimit()
    {
        // Only HighLimit present (HighHighLimit absent on this alarm type).
        var result = OpcUaAlarmMapper.PickLimitValue(null, 80.0, null, null);
        Assert.Equal("80", result);
    }

    [Fact]
    public void PickLimitValue_PriorityOrder_HighHighWinsOverHigh()
    {
        // When multiple limits are present, HighHighLimit takes precedence.
        var result = OpcUaAlarmMapper.PickLimitValue(95.0, 80.0, 20.0, 5.0);
        Assert.Equal("95", result);
    }

    [Fact]
    public void PickLimitValue_OnlyLowLow_ReturnsLowLow()
    {
        // LowLowLimit only — last in priority, but should still be returned.
        var result = OpcUaAlarmMapper.PickLimitValue(null, null, null, -10.5);
        Assert.Equal("-10.5", result);
    }

    [Fact]
    public void PickLimitValue_UsesInvariantCulture()
    {
        // Decimal separator must always be '.' regardless of thread culture.
        var result = OpcUaAlarmMapper.PickLimitValue(1.5, null, null, null);
        Assert.Contains('.', result); // invariant culture: '.' not ','
        Assert.Equal("1.5", result);
    }

    // ── MES alarm-status API §6.4: AckTime stamping ──

    private static readonly DateTimeOffset SourceAck =
        new(2026, 8, 1, 10, 0, 0, TimeSpan.Zero);
    private static readonly DateTimeOffset Observed =
        new(2026, 8, 1, 10, 5, 0, TimeSpan.Zero);

    [Fact]
    public void DeriveAckTime_ActiveAcked_PrefersTheSourcesOwnAckInstant()
    {
        // OPC UA A&C DOES supply a true ack time (AckedState/TransitionTime). When the
        // server sends it, it must win over the coarser observation time — that is the
        // whole point of selecting field 18.
        Assert.Equal(
            SourceAck,
            OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, SourceAck, Observed));
    }

    [Fact]
    public void DeriveAckTime_ActiveAcked_WithoutSourceTime_FallsBackToObservationTime()
    {
        // Servers may omit AckedState/TransitionTime (base ConditionType events, or a
        // server that does not expose it). The fallback is the event's own time — real,
        // just coarser. Never fabricated, never null-when-acked.
        Assert.Equal(
            Observed,
            OpcUaAlarmMapper.DeriveAckTime(active: true, acked: true, sourceAckTime: null, Observed));
    }

    [Fact]
    public void DeriveAckTime_Unacked_IsNull_EvenWhenSourceReportsAnAckTransition()
    {
        // AckedState/TransitionTime also stamps the flip BACK to unacked on a re-raise,
        // so it is present-but-meaningless there. The acked check is what makes "null
        // while unacked" and "cleared on re-raise" hold.
        Assert.Null(OpcUaAlarmMapper.DeriveAckTime(
            active: true, acked: false, sourceAckTime: SourceAck, Observed));
    }

    [Fact]
    public void DeriveAckTime_Inactive_IsNull()
    {
        // A cleared condition is on its way out of the mirror and reports no ack time,
        // matching the MxGateway mapper (where INACTIVE maps to acked = true).
        Assert.Null(OpcUaAlarmMapper.DeriveAckTime(
            active: false, acked: true, sourceAckTime: SourceAck, Observed));
    }
}
