using ZB.MOM.WW.ScadaBridge.Commons.Types.Alarms;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;

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

/// <summary>
/// Pure mapping helpers turning OPC UA Alarms &amp; Conditions event fields into the
/// protocol-neutral <see cref="AlarmConditionState"/> / transition shape. Kept
/// free of any OPC UA SDK types so it is unit-testable without a live server;
/// the SDK field extraction lives in <c>RealOpcUaClient</c> and is exercised by
/// the live smoke test.
/// </summary>
public static class OpcUaAlarmMapper
{
    /// <summary>
    /// Derives the two identity strings a native OPC UA alarm transition carries:
    /// the routing identity that <c>DataConnectionActor</c> matches against the stored
    /// binding, and the per-condition key used for state, persistence and display.
    /// </summary>
    /// <remarks>
    /// <para>
    /// <b>Why the routing identity is the binding string, not the event's name (Gitea #17).</b>
    /// Routing does <c>transition.SourceObjectReference.StartsWith(bindingRef)</c>, where the
    /// binding is a NodeId reference (the picker, CSV and config all store NodeIds). Deriving
    /// the routing identity from the event's <c>SourceName</c> — a plain name like the RawPath
    /// — never prefix-matches a NodeId binding, so every transition was silently dropped.
    /// Each OPC UA alarm feed is opened for exactly one binding, so every transition on it
    /// belongs to that binding: tagging the transition with <paramref name="subscriptionSourceReference"/>
    /// verbatim makes the routing key an exact match, independent of whether the binding is
    /// stored as <c>ns=&lt;index&gt;</c> or the durable <c>nsu=&lt;uri&gt;</c> form. An empty binding
    /// (the Server-object aggregate feed) stays empty, so it reaches only the "mirror everything"
    /// subscribers and never leaks into a specific-node binding.
    /// </para>
    /// <para>
    /// The per-condition key keeps the human-readable <paramref name="sourceName"/> (the RawPath
    /// for OtOpcUa) plus <paramref name="conditionName"/> so two conditions on one source stay
    /// distinct; it falls back to the binding reference only when the server leaves SourceName
    /// unset.
    /// </para>
    /// </remarks>
    /// <param name="subscriptionSourceReference">The binding string this alarm feed was subscribed under (empty for the Server-object aggregate feed).</param>
    /// <param name="sourceName">The event's <c>SourceName</c> field (the RawPath for OtOpcUa post-#473), or null/empty when unset.</param>
    /// <param name="conditionName">The event's <c>ConditionName</c> field, or null/empty when unset.</param>
    /// <returns>The per-condition <c>SourceReference</c> key and the <c>SourceObjectReference</c> routing identity.</returns>
    public static (string SourceReference, string SourceObjectReference) BuildIdentity(
        string? subscriptionSourceReference, string? sourceName, string? conditionName)
    {
        var routingRef = subscriptionSourceReference ?? "";
        var keyBase = !string.IsNullOrEmpty(sourceName) ? sourceName : routingRef;
        var sourceRef = string.IsNullOrEmpty(conditionName) ? keyBase : $"{keyBase}.{conditionName}";
        return (sourceRef, routingRef);
    }

    /// <summary>Clamps an OPC UA severity (1–1000, sometimes out of range) to the unified 0–1000 scale.</summary>
    /// <param name="severity">The raw OPC UA severity value to clamp.</param>
    /// <returns>The severity clamped to the range [0, 1000].</returns>
    public static int NormalizeSeverity(int severity) => Math.Clamp(severity, 0, 1000);

    /// <summary>Builds an <see cref="AlarmConditionState"/> from the orthogonal A&amp;C sub-states.</summary>
    /// <param name="active">Whether the alarm condition is currently active.</param>
    /// <param name="acked">Whether the alarm has been acknowledged.</param>
    /// <param name="confirmed">Whether the alarm has been confirmed; null if not supported by the server.</param>
    /// <param name="shelve">The current shelving state of the alarm.</param>
    /// <param name="suppressed">Whether the alarm is suppressed.</param>
    /// <param name="severity">The raw OPC UA severity (1–1000); clamped via <see cref="NormalizeSeverity"/>.</param>
    /// <returns>The composed <see cref="AlarmConditionState"/>.</returns>
    public static AlarmConditionState BuildCondition(
        bool active, bool acked, bool? confirmed, AlarmShelveState shelve, bool suppressed, int severity) =>
        new(Active: active, Acknowledged: acked, Confirmed: confirmed,
            Shelve: shelve, Suppressed: suppressed, Severity: NormalizeSeverity(severity));

    /// <summary>
    /// Derives the transition kind from the change in active/acked sub-states.
    /// Acknowledgement takes precedence over an active/inactive edge when both
    /// change in the same event; an unchanged event is reported as a StateChange.
    /// </summary>
    /// <param name="prevAcked">Acknowledged state before the event.</param>
    /// <param name="nowAcked">Acknowledged state after the event.</param>
    /// <param name="prevActive">Active state before the event.</param>
    /// <param name="nowActive">Active state after the event.</param>
    /// <returns>The <see cref="AlarmTransitionKind"/> that best describes the state change.</returns>
    public static AlarmTransitionKind DeriveKind(bool prevAcked, bool nowAcked, bool prevActive, bool nowActive)
    {
        if (!prevAcked && nowAcked)
            return AlarmTransitionKind.Acknowledge;
        if (!prevActive && nowActive)
            return AlarmTransitionKind.Raise;
        if (prevActive && !nowActive)
            return AlarmTransitionKind.Clear;
        if (prevActive && nowActive)
            return AlarmTransitionKind.Retrigger;
        return AlarmTransitionKind.StateChange;
    }

    /// <summary>
    /// Derives the ack timestamp mirrored onto <c>NativeAlarmTransition.AckTime</c>
    /// (MES alarm-status API §6.4).
    ///
    /// <para>
    /// OPC UA A&amp;C DOES supply a true ack instant: <c>AckedState/TransitionTime</c> is the
    /// UTC time the AckedState last flipped, so when the condition is acknowledged that field
    /// IS the moment of acknowledgement. It is an optional event field (absent on base
    /// ConditionType events and on servers that do not expose it), hence
    /// <paramref name="sourceAckTime"/> is nullable and falls back to the event's own
    /// <paramref name="observedAt"/> time — the instant the DCL observed the acknowledged
    /// state. Never fabricated: both values are real timestamps, the fallback is simply
    /// coarser.
    /// </para>
    ///
    /// <para>
    /// Returns <c>null</c> unless the condition is active AND acknowledged. That single rule
    /// gives the two behaviours the design calls for: null while unacked, and cleared on
    /// re-raise (a re-raise arrives with <c>acked == false</c>). A cleared condition
    /// (inactive) is on its way out of the mirror and reports no ack time either.
    /// </para>
    /// </summary>
    /// <param name="active">Whether the condition is currently active.</param>
    /// <param name="acked">Whether the condition is currently acknowledged.</param>
    /// <param name="sourceAckTime">The source's <c>AckedState/TransitionTime</c>, or null when the server omits it.</param>
    /// <param name="observedAt">The event's own transition time — the DCL's observation instant.</param>
    /// <returns>The ack timestamp, or <c>null</c> when the condition is not an outstanding acknowledged alarm.</returns>
    public static DateTimeOffset? DeriveAckTime(
        bool active, bool acked, DateTimeOffset? sourceAckTime, DateTimeOffset observedAt) =>
        active && acked ? sourceAckTime ?? observedAt : null;

    /// <summary>Maps the OPC UA ShelvingState current-state node name to the shelve enum.</summary>
    /// <param name="shelvingStateName">The OPC UA ShelvingState node name, or null when unshelved.</param>
    /// <returns>The corresponding <see cref="AlarmShelveState"/>; defaults to <see cref="AlarmShelveState.Unshelved"/>.</returns>
    public static AlarmShelveState MapShelve(string? shelvingStateName) => shelvingStateName switch
    {
        "OneShotShelved" => AlarmShelveState.OneShotShelved,
        "TimedShelved" => AlarmShelveState.TimedShelved,
        // OPC UA does not expose a distinct "permanent" shelve; treat any other
        // shelved name as one-shot and "Unshelved"/null as unshelved.
        null or "Unshelved" => AlarmShelveState.Unshelved,
        _ => AlarmShelveState.OneShotShelved
    };

    /// <summary>
    /// Picks a representative display-only limit value from the four standard
    /// <c>LimitAlarmType</c> set-point fields (HighHighLimit, HighLimit, LowLimit,
    /// LowLowLimit) returned by the OPC UA event SelectClause.
    ///
    /// <para>
    /// The fields are absent (null raw value) on non-limit alarm types (discrete,
    /// off-normal, etc.).  When present, the first non-null value is returned in
    /// priority order: HighHigh → High → Low → LowLow.  The caller may use
    /// <c>AlarmTypeName</c> or <c>ConditionName</c> to determine which specific
    /// limit is active; this method intentionally returns the coarsest useful value
    /// for the common single-limit case without requiring callers to understand the
    /// OPC UA limit hierarchy.
    /// </para>
    /// </summary>
    /// <param name="highHighRaw">Raw HighHighLimit field value (null when absent).</param>
    /// <param name="highRaw">Raw HighLimit field value (null when absent).</param>
    /// <param name="lowRaw">Raw LowLimit field value (null when absent).</param>
    /// <param name="lowLowRaw">Raw LowLowLimit field value (null when absent).</param>
    /// <returns>
    /// A formatted string representation of the first non-null limit value, or an
    /// empty string when all four fields are absent (non-limit alarm type).
    /// </returns>
    public static string PickLimitValue(object? highHighRaw, object? highRaw, object? lowRaw, object? lowLowRaw)
    {
        // Standard OPC UA LimitAlarmType limit values are numeric (Double/Float/Int).
        // Convert with InvariantCulture so the decimal separator is always '.' regardless
        // of the server's locale.
        foreach (var raw in new[] { highHighRaw, highRaw, lowRaw, lowLowRaw })
        {
            if (raw is not null)
                return Convert.ToString(raw, System.Globalization.CultureInfo.InvariantCulture) ?? "";
        }
        return "";
    }
}
