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

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

/// <summary>
///     Polls each device's CNC active-alarm list via <see cref="IFocasClient.ReadAlarmsAsync"/>
///     on a timer and translates raise / clear transitions into <see cref="IAlarmSource"/>
///     events on the owning <see cref="FocasDriver"/>. One poll loop per subscription; the
///     loop fans out across every configured device and diffs the (<c>AlarmNumber</c>,
///     <c>Type</c>) keyed active-alarm set between ticks.
/// </summary>
/// <remarks>
///     FOCAS alarms are flat per session — the CNC exposes a single active-alarm list via
///     <c>cnc_rdalmmsg2</c>, not per-node structures the way Galaxy / AbCip ALMD do. So the
///     projection ignores <c>sourceNodeIds</c> at the member level: every alarm event is
///     raised with <c>SourceNodeId=device-host-address</c>. Callers that want per-device
///     filtering can pass the specific host addresses as <c>sourceNodeIds</c> and the
///     projection will skip devices not listed.
/// </remarks>
internal sealed class FocasAlarmProjection : IAsyncDisposable
{
    private readonly FocasDriver _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 FOCAS alarm projection.</summary>
    /// <param name="driver">FOCAS driver instance.</param>
    /// <param name="pollInterval">Polling interval.</param>
    /// <param name="logger">Optional logger.</param>
    public FocasAlarmProjection(FocasDriver driver, TimeSpan pollInterval, ILogger? logger = null)
    {
        _driver = driver;
        _pollInterval = pollInterval;
        _logger = logger ?? NullLogger.Instance;
    }

    /// <summary>Subscribes to alarms from the specified device sources.</summary>
    /// <param name="sourceNodeIds">Source node IDs to listen to.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A task returning the alarm subscription handle.</returns>
    public Task<IAlarmSubscriptionHandle> SubscribeAsync(
        IReadOnlyList<string> sourceNodeIds, CancellationToken cancellationToken)
    {
        var id = Interlocked.Increment(ref _nextId);
        var handle = new FocasAlarmSubscriptionHandle(id);
        var cts = new CancellationTokenSource();
        // Empty filter = listen to every configured device. Otherwise only devices whose
        // host address appears in sourceNodeIds are polled.
        var filter = sourceNodeIds.Count == 0
            ? null
            : new HashSet<string>(sourceNodeIds, StringComparer.OrdinalIgnoreCase);
        var sub = new Subscription(handle, filter, 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];
            _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);

        return Task.FromResult<IAlarmSubscriptionHandle>(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 async Task RetireAsync(Subscription sub)
    {
        try { sub.Cts.Cancel(); }
        catch (Exception ex) { _logger.LogDebug(ex, "Cancelling superseded alarm-subscription CTS failed"); }
        try { await sub.Loop.ConfigureAwait(false); }
        catch (Exception ex) { _logger.LogDebug(ex, "Awaiting superseded alarm-subscription loop failed during retire"); }
        try { sub.Cts.Dispose(); }
        catch (Exception ex) { _logger.LogDebug(ex, "Disposing superseded alarm-subscription CTS failed"); }
    }

    /// <summary>Unsubscribes from an alarm subscription.</summary>
    /// <param name="handle">Alarm subscription handle.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A task representing the asynchronous operation.</returns>
    public async Task UnsubscribeAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken)
    {
        if (handle is not FocasAlarmSubscriptionHandle h) return;
        Subscription? sub;
        lock (_subsLock)
        {
            if (!_subs.Remove(h.Id, out sub)) return;
        }
        try { sub.Cts.Cancel(); }
        catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS failed"); }
        try { await sub.Loop.ConfigureAwait(false); }
        catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop failed during unsubscribe"); }
        sub.Cts.Dispose();
    }

    /// <summary>
    ///     FOCAS has no ack wire call — the CNC clears alarms on its own when the underlying
    ///     condition resolves. Swallow the request so capability negotiation succeeds, rather
    ///     than surfacing a confusing "not supported" error to the operator.
    /// </summary>
    /// <param name="acknowledgements">Alarms to acknowledge.</param>
    /// <param name="cancellationToken">Cancellation token.</param>
    /// <returns>A completed task.</returns>
    public Task AcknowledgeAsync(
        IReadOnlyList<AlarmAcknowledgeRequest> acknowledgements, CancellationToken cancellationToken) =>
        Task.CompletedTask;

    /// <summary>Disposes the alarm projection.</summary>
    /// <returns>A task representing the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        List<Subscription> snap;
        lock (_subsLock) { snap = [.. _subs.Values]; _subs.Clear(); }
        foreach (var sub in snap)
        {
            try { sub.Cts.Cancel(); }
            catch (Exception ex) { _logger.LogDebug(ex, "Cancelling alarm-subscription CTS during dispose failed"); }
            try { await sub.Loop.ConfigureAwait(false); }
            catch (Exception ex) { _logger.LogDebug(ex, "Awaiting alarm-subscription loop during dispose failed"); }
            sub.Cts.Dispose();
        }
    }

    /// <summary>
    ///     One poll-tick for one device. Diffs the new alarm list against the previous snapshot,
    ///     emits raise + clear events. Extracted so tests can drive a tick without spinning up
    ///     the full Task.Run loop.
    /// </summary>
    /// <param name="sub">Active subscription.</param>
    /// <param name="deviceHostAddress">Device host address.</param>
    /// <param name="current">Current alarms from the device.</param>
    internal void Tick(Subscription sub, string deviceHostAddress, IReadOnlyList<FocasActiveAlarm> current)
    {
        var prev = sub.LastByDevice.GetValueOrDefault(deviceHostAddress) ?? [];
        var nowKeys = current.Select(a => AlarmKey(a)).ToHashSet();
        var prevKeys = prev.Select(a => AlarmKey(a)).ToHashSet();

        foreach (var a in current)
        {
            if (prevKeys.Contains(AlarmKey(a))) continue;
            _driver.InvokeAlarmEvent(new AlarmEventArgs(
                sub.Handle,
                SourceNodeId: deviceHostAddress,
                ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
                AlarmType: MapAlarmType(a.Type),
                Message: a.Message,
                Severity: MapSeverity(a.Type),
                SourceTimestampUtc: DateTime.UtcNow));
        }

        foreach (var a in prev)
        {
            if (nowKeys.Contains(AlarmKey(a))) continue;
            _driver.InvokeAlarmEvent(new AlarmEventArgs(
                sub.Handle,
                SourceNodeId: deviceHostAddress,
                ConditionId: $"{deviceHostAddress}#{AlarmKey(a)}",
                AlarmType: MapAlarmType(a.Type),
                Message: $"{a.Message} (cleared)",
                Severity: MapSeverity(a.Type),
                SourceTimestampUtc: DateTime.UtcNow));
        }

        sub.LastByDevice[deviceHostAddress] = [.. current];
    }

    private async Task RunPollLoopAsync(Subscription sub, CancellationToken ct)
    {
        while (!ct.IsCancellationRequested)
        {
            try
            {
                foreach (var (host, alarms) in await _driver.ReadActiveAlarmsAcrossDevicesAsync(sub.DeviceFilter, ct).ConfigureAwait(false))
                {
                    Tick(sub, host, alarms);
                }
            }
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
            catch (Exception ex)
            {
                /* per-tick failures are non-fatal — next tick retries */
                _logger.LogDebug(ex, "FOCAS alarm-projection poll tick failed");
            }

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

    private static string AlarmKey(FocasActiveAlarm a) => $"{a.Type}:{a.AlarmNumber}";

    /// <summary>Map FOCAS type to a human-readable category; falls back to the numeric type.</summary>
    /// <param name="type">FOCAS alarm type.</param>
    /// <returns>The mapped alarm type string.</returns>
    internal static string MapAlarmType(short type) => type switch
    {
        FocasAlarmType.Parameter   => "Parameter",
        FocasAlarmType.PulseCode   => "PulseCode",
        FocasAlarmType.Overtravel  => "Overtravel",
        FocasAlarmType.Overheat    => "Overheat",
        FocasAlarmType.Servo       => "Servo",
        FocasAlarmType.DataIo      => "DataIo",
        FocasAlarmType.MemoryCheck => "MemoryCheck",
        FocasAlarmType.MacroAlarm  => "MacroAlarm",
        _                          => $"Type{type}",
    };

    /// <summary>
    ///     Project FOCAS alarm types into the driver-agnostic 4-band severity. Overtravel /
    ///     Servo / Emergency-equivalents are Critical; Parameter + Macro are Medium; rest land
    ///     at High (everything else on a CNC is safety-relevant).
    /// </summary>
    /// <param name="type">FOCAS alarm type.</param>
    /// <returns>The mapped alarm severity.</returns>
    internal static AlarmSeverity MapSeverity(short type) => type switch
    {
        FocasAlarmType.Overtravel => AlarmSeverity.Critical,
        FocasAlarmType.Servo      => AlarmSeverity.Critical,
        FocasAlarmType.PulseCode  => AlarmSeverity.Critical,
        FocasAlarmType.Parameter  => AlarmSeverity.Medium,
        FocasAlarmType.MacroAlarm => AlarmSeverity.Medium,
        _                         => AlarmSeverity.High,
    };

    internal sealed class Subscription(
        FocasAlarmSubscriptionHandle handle,
        HashSet<string>? deviceFilter,
        CancellationTokenSource cts)
    {
        /// <summary>Gets the subscription handle.</summary>
        public FocasAlarmSubscriptionHandle Handle { get; } = handle;
        /// <summary>Gets the device filter.</summary>
        public HashSet<string>? DeviceFilter { get; } = deviceFilter;
        /// <summary>Gets the cancellation token source.</summary>
        public CancellationTokenSource Cts { get; } = cts;
        /// <summary>Gets or sets the polling loop task.</summary>
        public Task Loop { get; set; } = Task.CompletedTask;
        /// <summary>Gets the last seen alarms by device.</summary>
        public Dictionary<string, IReadOnlyList<FocasActiveAlarm>> LastByDevice { get; } =
            new(StringComparer.OrdinalIgnoreCase);
    }
}

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