using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;

/// <summary>
///     Projects AB Logix ALMD alarm instructions onto the OPC UA alarm surface by
///     polling the ALMD UDT's <c>InFaulted</c> / <c>Acked</c> / <c>Severity</c> members at a
///     configurable interval + translating state transitions into <c>OnAlarmEvent</c>
///     callbacks on the owning <see cref="AbCipDriver"/>. Feature-flagged off by default via
///     <see cref="AbCipDriverOptions.EnableAlarmProjection"/>; callers that leave the flag off
///     get a no-op subscribe path so capability negotiation still works.
/// </summary>
/// <remarks>
///     <para>ALMD-only in this pass. ALMA (analog alarm) projection is a follow-up because
///     its threshold + limit semantics need more design — ALMD's "is the alarm active + has
///     the operator acked" shape maps cleanly onto the driver-agnostic
///     <see cref="IAlarmSource"/> contract without concessions.</para>
///
///     <para>Polling reuses <see cref="AbCipDriver.ReadAsync"/>, so ALMD reads get the
///     whole-UDT optimization for free when the ALMD is declared with its standard members.
///     One poll loop per subscription call; the loop batches every
///     member read across the full source-node set into a single ReadAsync per tick.</para>
///
///     <para>ALMD <c>Acked</c> write semantics on Logix are rising-edge sensitive at the
///     instruction level — writing <c>Acked=1</c> directly is honored by FT View + the
///     standard HMI templates, but some PLC programs read <c>AckCmd</c> + look for the edge
///     themselves. We pick the simpler <c>Acked</c> write for first pass; operators whose
///     ladder watches <c>AckCmd</c> can wire a follow-up "AckCmd 0→1→0" pulse on the client
///     side until a driver-level knob lands.</para>
/// </remarks>
internal sealed class AbCipAlarmProjection : IAsyncDisposable
{
    private readonly AbCipDriver _driver;
    private readonly TimeSpan _pollInterval;
    private readonly ILogger _logger;
    private readonly Dictionary<long, Subscription> _subs = new();
    private readonly Lock _subsLock = new();
    private long _nextId;

    /// <summary>Initializes a new instance of the <see cref="AbCipAlarmProjection"/> class.</summary>
    /// <param name="driver">The AB CIP driver instance.</param>
    /// <param name="pollInterval">The interval at which to poll for alarm state changes.</param>
    /// <param name="logger">Optional logger instance.</param>
    public AbCipAlarmProjection(AbCipDriver driver, TimeSpan pollInterval, ILogger? logger = null)
    {
        _driver = driver;
        _pollInterval = pollInterval;
        _logger = logger ?? NullLogger.Instance;
    }

    /// <summary>Subscribes to alarm events for the specified source nodes.</summary>
    /// <param name="sourceNodeIds">The node identifiers to monitor for alarm state changes.</param>
    /// <param name="cancellationToken">A cancellation token to stop the operation.</param>
    /// <returns>A subscription handle for managing the subscription.</returns>
    public async Task<IAlarmSubscriptionHandle> SubscribeAsync(
        IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
    {
        var id = Interlocked.Increment(ref _nextId);
        var handle = new AbCipAlarmSubscriptionHandle(id);
        var cts = new CancellationTokenSource();
        var sub = new Subscription(handle, [..sourceNodeIds], cts);

        // Collapse to a SINGLE active poll loop. The owning DriverInstanceActor re-subscribes
        // alarms on every Connected entry (its DetachAlarmSource nulls the cached handle on
        // Connected→Reconnecting WITHOUT calling Unsubscribe), and this projection is reused across
        // every in-place reconnect, so each SubscribeAsync would otherwise leak a live, never-
        // cancelled poll loop. There is exactly one consumer per driver instance, so retiring all
        // prior subscriptions before starting the new one is semantically faithful. Snapshotting +
        // clearing under the same lock DisposeAsync uses guarantees the stale subs can never be
        // double-owned (and thus never double-disposed) by a racing dispose.
        List<Subscription> stale;
        lock (_subsLock)
        {
            stale = _subs.Values.ToList();
            _subs.Clear();
            _subs[id] = sub;
        }

        sub.Loop = Task.Run(() => RunPollLoopAsync(sub, cts.Token), cts.Token);

        // Retire superseded loops out-of-band so the new subscription's return isn't blocked on a
        // full poll interval (awaiting a loop means waiting for it to exit its Task.Delay). The
        // loops already catch internally; RetireAsync still wraps every await defensively.
        foreach (var old in stale) _ = RetireAsync(old);

        await Task.CompletedTask;
        return handle;
    }

    /// <summary>
    ///     Cancels a superseded subscription's poll loop, waits for it to wind down, and disposes
    ///     its CTS. Fire-and-forget from <see cref="SubscribeAsync"/>; every step is wrapped so an
    ///     unobserved exception can never escape (the loops already swallow their own).
    ///     <para>
    ///         This is NOT a zero-overlap guarantee: the retired loop keeps running until its in-flight
    ///         read + tick complete and it observes cancellation at the next <c>Task.Delay</c>. If a device
    ///         transition lands in that one-tick window, both the old and new loops can fire it once —
    ///         i.e. at most ONE duplicate raise/clear per reconnect, transient and self-correcting (the old
    ///         loop then exits and the new loop owns the only state). Upstream Part 9 conditions dedupe on
    ///         <c>ConditionId</c>, so this is absorbed. Awaiting the retire before starting the new loop
    ///         would close the window at the cost of blocking the subscribe for one read latency.
    ///     </para>
    /// </summary>
    /// <param name="sub">The retired subscription whose loop must be cancelled + disposed.</param>
    private static async Task RetireAsync(Subscription sub)
    {
        try { sub.Cts.Cancel(); } catch { }
        try { await sub.Loop.ConfigureAwait(false); } catch { }
        try { sub.Cts.Dispose(); } catch { }
    }

    /// <summary>Unsubscribes from alarm events using the provided subscription handle.</summary>
    /// <param name="handle">The subscription handle obtained from <see cref="SubscribeAsync"/>.</param>
    /// <param name="cancellationToken">A cancellation token to stop the operation.</param>
    /// <returns>A task representing the asynchronous unsubscribe operation.</returns>
    public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
    {
        if (handle is not AbCipAlarmSubscriptionHandle h) return;
        Subscription? sub;
        lock (_subsLock)
        {
            if (!_subs.Remove(h.Id, out sub)) return;
        }
        try { sub.Cts.Cancel(); } catch { }
        try { await sub.Loop.ConfigureAwait(false); } catch { }
        sub.Cts.Dispose();
    }

    /// <summary>Acknowledges one or more active alarms.</summary>
    /// <param name="acknowledgements">The list of acknowledgement requests specifying which alarms to acknowledge.</param>
    /// <param name="cancellationToken">A cancellation token to stop the operation.</param>
    /// <returns>A task representing the asynchronous acknowledgement operation.</returns>
    public async Task AcknowledgeAsync(
        IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken)
    {
        if (acknowledgements.Count == 0) return;

        // Write Acked=1 per request. IWritable isn't on AbCipAlarmProjection so route through
        // the driver's public interface — delegating instead of re-implementing the write path
        // keeps the bit-in-DINT + idempotency + per-call-host-resolve knobs intact.
        var requests = acknowledgements
            .Select(a => new WriteRequest($"{a.SourceNodeId}.Acked", true))
            .ToArray();
        // Best-effort — the driver's WriteAsync returns per-item status; individual ack
        // failures don't poison the batch. Swallow the return so a single faulted ack
        // doesn't bubble out of the caller's batch expectation.
#pragma warning disable OTOPCUA0001 // Driver-INTERNAL self-call: the projection is owned by AbCipDriver; CapabilityInvoker wraps the driver from the dispatch layer OUTWARD, so a driver's own internal ack path must NOT re-wrap. Intentional, not a dispatch gap.
        _ = await _driver.WriteAsync(requests, cancellationToken).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
    }

    /// <summary>Releases all resources associated with this alarm projection.</summary>
    /// <returns>A task representing the asynchronous disposal operation.</returns>
    public async ValueTask DisposeAsync()
    {
        List<Subscription> snap;
        lock (_subsLock) { snap = _subs.Values.ToList(); _subs.Clear(); }
        foreach (var sub in snap)
        {
            try { sub.Cts.Cancel(); } catch { }
            try { await sub.Loop.ConfigureAwait(false); } catch { }
            sub.Cts.Dispose();
        }
    }

    /// <summary>
    ///     Poll-tick body — reads <c>InFaulted</c> + <c>Severity</c> for every source node id
    ///     in the subscription, diffs each against last-seen state, fires raise/clear events.
    ///     Extracted so tests can drive one tick without standing up the Task.Run loop.
    /// </summary>
    /// <param name="sub">The subscription to process.</param>
    /// <param name="results">The data values read from the subscription source nodes.</param>
    internal void Tick(Subscription sub, IReadOnlyList<DataValueSnapshot> results)
    {
        // results index layout: for each sourceNode, [InFaulted, Severity] in order.
        for (var i = 0; i < sub.SourceNodeIds.Count; i++)
        {
            var nodeId = sub.SourceNodeIds[i];
            var inFaultedDv = results[i * 2];
            var severityDv = results[i * 2 + 1];
            if (inFaultedDv.StatusCode != AbCipStatusMapper.Good) continue;

            var nowFaulted = ToBool(inFaultedDv.Value);
            // severityDv.StatusCode is not checked here. When the Severity read is Bad (value null),
            // ToInt(null) returns 0 and MapSeverity buckets it as Low. This is acceptable because
            // InFaulted and Severity are members of the same ALMD UDT read in one batch, so a Good
            // InFaulted almost always implies a Good Severity. The "unknown severity → Low" fallback
            // is intentional and matches the documented behaviour.
            var severity = ToInt(severityDv.Value);

            var wasFaulted = sub.LastInFaulted.GetValueOrDefault(nodeId, false);
            sub.LastInFaulted[nodeId] = nowFaulted;

            if (!wasFaulted && nowFaulted)
            {
                _driver.InvokeAlarmEvent(new AlarmEventArgs(
                    sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
                    AlarmType: "ALMD",
                    Message: $"ALMD {nodeId} raised",
                    Severity: MapSeverity(severity),
                    SourceTimestampUtc: DateTime.UtcNow));
            }
            else if (wasFaulted && !nowFaulted)
            {
                _driver.InvokeAlarmEvent(new AlarmEventArgs(
                    sub.Handle, nodeId, ConditionId: $"{nodeId}#active",
                    AlarmType: "ALMD",
                    Message: $"ALMD {nodeId} cleared",
                    Severity: MapSeverity(severity),
                    SourceTimestampUtc: DateTime.UtcNow));
            }
        }
    }

    private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
    {
        var refs = new List<string>(sub.SourceNodeIds.Count * 2);
        foreach (var nodeId in sub.SourceNodeIds)
        {
            refs.Add($"{nodeId}.InFaulted");
            refs.Add($"{nodeId}.Severity");
        }

        while (!ct.IsCancellationRequested)
        {
            try
            {
#pragma warning disable OTOPCUA0001 // Driver-INTERNAL self-call: the projection is owned by AbCipDriver; CapabilityInvoker wraps the driver from the dispatch layer OUTWARD, so a driver's own internal poll path must NOT re-wrap. Intentional, not a dispatch gap.
                var results = await _driver.ReadAsync(refs, ct).ConfigureAwait(false);
#pragma warning restore OTOPCUA0001
                Tick(sub, results);
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch (Exception ex)
            {
                // Per-tick failures are non-fatal; next tick retries. Log at debug because a
                // wedged controller produces one exception per tick and the operator already
                // sees the failed-read warning from ReadAsync below this layer; this log just
                // confirms the alarm projection loop is still running.
                _logger.LogDebug(ex, "AbCip alarm-projection poll tick failed (will retry)");
            }

            try { await Task.Delay(_pollInterval, ct).ConfigureAwait(false); }
            catch (OperationCanceledException) { break; }
        }
    }

    /// <summary>Maps a raw severity value to an <see cref="AlarmSeverity"/> enum value.</summary>
    /// <param name="raw">The raw severity value from the alarm data.</param>
    /// <returns>The corresponding alarm severity level.</returns>
    internal static AlarmSeverity MapSeverity(int raw) => raw switch
    {
        <= 250 => AlarmSeverity.Low,
        <= 500 => AlarmSeverity.Medium,
        <= 750 => AlarmSeverity.High,
        _ => AlarmSeverity.Critical,
    };

    private static bool ToBool(object? v) => v switch
    {
        bool b => b,
        int i => i != 0,
        long l => l != 0,
        _ => false,
    };

    private static int ToInt(object? v) => v switch
    {
        int i => i,
        long l => (int)l,
        short s => s,
        byte b => b,
        _ => 0,
    };

    internal sealed class Subscription
    {
        /// <summary>Initializes a new instance of the <see cref="Subscription"/> class.</summary>
        /// <param name="handle">The subscription handle.</param>
        /// <param name="sourceNodeIds">The source node identifiers to monitor.</param>
        /// <param name="cts">The cancellation token source for stopping the subscription.</param>
        public Subscription(AbCipAlarmSubscriptionHandle handle, IReadOnlyList<string> sourceNodeIds, CancellationTokenSource cts)
        {
            Handle = handle; SourceNodeIds = sourceNodeIds; Cts = cts;
        }

        /// <summary>Gets the subscription handle.</summary>
        public AbCipAlarmSubscriptionHandle Handle { get; }

        /// <summary>Gets the source node identifiers being monitored.</summary>
        public IReadOnlyList<string> SourceNodeIds { get; }

        /// <summary>Gets the cancellation token source for this subscription.</summary>
        public CancellationTokenSource Cts { get; }

        /// <summary>Gets or sets the polling loop task.</summary>
        public Task Loop { get; set; } = Task.CompletedTask;

        /// <summary>Gets the dictionary tracking the last known InFaulted state for each node.</summary>
        public Dictionary<string, bool> LastInFaulted { get; } = new(StringComparer.Ordinal);
    }
}

/// <summary>Handle returned by <see cref="AbCipAlarmProjection.SubscribeAsync"/>.</summary>
public sealed record AbCipAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
{
    /// <inheritdoc />
    public string DiagnosticId => $"abcip-alarm-sub-{Id}";
}

/// <summary>
///     Detects the ALMD / ALMA signature in an <see cref="AbCipTagDefinition"/>'s declared
///     members. Used by both discovery (to stamp <c>IsAlarm=true</c> on the emitted
///     variable) + initial driver setup (to decide which tags the alarm projection owns).
/// </summary>
public static class AbCipAlarmDetector
{
    /// <summary>
    ///     <c>true</c> when <paramref name="tag"/> is a Structure whose declared members match
    ///     the ALMD signature (<c>InFaulted</c> + <c>Acked</c> present). ALMA detection
    ///     (analog alarms with <c>HHLimit</c>/<c>HLimit</c>/<c>LLimit</c>/<c>LLLimit</c>)
    ///     ships as a follow-up.
    /// </summary>
    /// <param name="tag">The tag definition to check for ALMD signature.</param>
    /// <returns>True if the tag has the ALMD alarm signature; false otherwise.</returns>
    public static bool IsAlmd(AbCipTagDefinition tag)
    {
        if (tag.DataType != AbCipDataType.Structure || tag.Members is null) return false;
        var names = tag.Members.Select(m => m.Name).ToHashSet(StringComparer.OrdinalIgnoreCase);
        return names.Contains("InFaulted") && names.Contains("Acked");
    }
}
