using System.Collections;
using System.Collections.Concurrent;

namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;

/// <summary>
///     Shared poll-based subscription engine for drivers whose underlying protocol has no
///     native push model (Modbus, AB CIP, S7, FOCAS). Owns one background Task per subscription
///     that periodically invokes the supplied reader, diffs each snapshot against the last
///     known value, and dispatches a change callback per changed tag. Extracted from
///     <c>ModbusDriver</c> (AB CIP PR 1) so poll-based drivers don't each re-ship the loop,
///     floor logic, and lifecycle plumbing.
/// </summary>
/// <remarks>
///     <para>The engine is read-path agnostic: it calls the supplied <c>reader</c> delegate
///     and trusts the driver to map protocol errors into <see cref="DataValueSnapshot.StatusCode"/>.
///     Callbacks fire on: (a) the first poll after subscribe (initial-data push per the OPC UA
///     Part 4 convention), (b) any subsequent poll where the boxed value or status code differs
///     from the previously-seen snapshot.</para>
///
///     <para>Exceptions thrown by the reader on the initial poll or any subsequent poll are
///     caught — the loop continues on the next tick. When an <c>onError</c> callback is supplied
///     to the constructor the caught exception is routed to it so the driver's health surface
///     can record the failure. Without an <c>onError</c> callback the exception is silently
///     swallowed (preserves the original behaviour for drivers that have not opted in yet).</para>
///
///     <para>Programmer errors and obviously-fatal exceptions (<see cref="OutOfMemoryException"/>,
///     <see cref="ThreadAbortException"/>, <see cref="StackOverflowException"/>,
///     <see cref="AccessViolationException"/>) are NOT caught — they propagate and tear the poll
///     loop down rather than spin a silently-broken subscription.</para>
/// </remarks>
public sealed class PollGroupEngine : IAsyncDisposable
{
    private readonly Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> _reader;
    private readonly Action<ISubscriptionHandle, string, DataValueSnapshot> _onChange;
    private readonly Action<Exception>? _onError;
    private readonly TimeSpan _minInterval;
    private readonly TimeSpan? _backoffCap;
    private readonly ConcurrentDictionary<long, SubscriptionState> _subscriptions = new();
    private long _nextId;

    /// <summary>Default floor for publishing intervals — matches the Modbus 100 ms cap.</summary>
    public static readonly TimeSpan DefaultMinInterval = TimeSpan.FromMilliseconds(100);

    /// <summary>Constructs the engine with the driver-supplied reader, change callback, and optional interval floor / error sink.</summary>
    /// <param name="reader">Driver-supplied batch reader; snapshots MUST be returned in the same
    ///     order as the input references.</param>
    /// <param name="onChange">Callback invoked per changed tag — the driver forwards to its own
    ///     <see cref="ISubscribable.OnDataChange"/> event.</param>
    /// <param name="minInterval">Interval floor; anything below is clamped. Defaults to 100 ms
    ///     per <see cref="DefaultMinInterval"/>.</param>
    /// <param name="onError">Optional error sink — invoked once per caught reader exception (or
    ///     internal contract-violation throw) so the owning driver can route the failure to its
    ///     health surface. Defensive: an <c>onError</c> handler that
    ///     itself throws is silently absorbed so a buggy forwarder cannot crash the poll loop.</param>
    /// <param name="backoffCap">Optional cap enabling capped-exponential failure backoff
    ///     (05/STAB-8). When supplied, sustained reader failures stretch the retry cadence
    ///     (interval, 2×, 4×, … saturating at this cap) instead of hammering a dead device every
    ///     tick; a successful poll resets to the base interval. <c>null</c> (default) keeps the
    ///     byte-identical fixed-interval cadence for consumers that have not opted in.</param>
    public PollGroupEngine(
        Func<IReadOnlyList<string>, CancellationToken, Task<IReadOnlyList<DataValueSnapshot>>> reader,
        Action<ISubscriptionHandle, string, DataValueSnapshot> onChange,
        TimeSpan? minInterval = null,
        Action<Exception>? onError = null,
        TimeSpan? backoffCap = null)
    {
        ArgumentNullException.ThrowIfNull(reader);
        ArgumentNullException.ThrowIfNull(onChange);
        _reader = reader;
        _onChange = onChange;
        _onError = onError;
        _minInterval = minInterval ?? DefaultMinInterval;
        _backoffCap = backoffCap;
    }

    /// <summary>Register a new polled subscription and start its background loop.</summary>
    /// <param name="fullReferences">The list of tag references to poll.</param>
    /// <param name="publishingInterval">The desired polling interval; will be clamped to the configured minimum.</param>
    /// <returns>A handle identifying the new subscription, for later use with <see cref="Unsubscribe"/>.</returns>
    public ISubscriptionHandle Subscribe(IReadOnlyList<string> fullReferences, TimeSpan publishingInterval)
    {
        ArgumentNullException.ThrowIfNull(fullReferences);
        var id = Interlocked.Increment(ref _nextId);
        var cts = new CancellationTokenSource();
        var interval = publishingInterval < _minInterval ? _minInterval : publishingInterval;
        var handle = new PollSubscriptionHandle(id);
        var state = new SubscriptionState(handle, [.. fullReferences], interval, cts);
        _subscriptions[id] = state;
        state.LoopTask = Task.Run(() => PollLoopAsync(state, cts.Token));
        return handle;
    }

    /// <summary>Cancel the background loop for a handle returned by <see cref="Subscribe"/>.</summary>
    /// <returns><c>true</c> when the handle was known to the engine and has been torn down.</returns>
    /// <param name="handle">The subscription handle to cancel.</param>
    public bool Unsubscribe(ISubscriptionHandle handle)
    {
        if (handle is PollSubscriptionHandle h && _subscriptions.TryRemove(h.Id, out var state))
        {
            StopState(state);
            return true;
        }
        return false;
    }

    private static void StopState(SubscriptionState state)
    {
        try { state.Cts.Cancel(); } catch { }
        // Await the loop task (with a generous timeout) before disposing the CTS so:
        //  (a) no _onChange callback fires after the caller considers the engine torn down, and
        //  (b) the CTS is not disposed while Task.Delay is still holding a reference to its token,
        //      which can turn OperationCanceledException into ObjectDisposedException.
        var task = state.LoopTask;
        if (task is not null)
        {
            try { task.Wait(TimeSpan.FromSeconds(5)); } catch { }
        }
        state.Cts.Dispose();
    }

    /// <summary>Snapshot of active subscription count — exposed for driver diagnostics.</summary>
    public int ActiveSubscriptionCount => _subscriptions.Count;

    private async Task PollLoopAsync(SubscriptionState state, CancellationToken ct)
    {
        var consecutiveFailures = 0;

        // Initial-data push: every subscribed tag fires once at subscribe time regardless of
        // whether it has changed, satisfying OPC UA Part 4 initial-value semantics.
        try
        {
            await PollOnceAsync(state, forceRaise: true, ct).ConfigureAwait(false);
            consecutiveFailures = 0;
        }
        // Only a teardown OCE (the loop's OWN token) exits the loop. A reader that throws OCE with
        // the loop token un-cancelled — a driver-internal timeout CTS (the STAB-14/STAB-15 class) —
        // must NOT tear the loop down: it falls through to the generic handler, is reported, and the
        // loop retries with backoff. This is the guard that makes the S7 fork migration safe.
        catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
        catch (Exception ex) when (!IsFatal(ex))
        {
            // first-read error tolerated — loop continues; forward to driver health surface.
            consecutiveFailures++;
            ReportError(ex);
        }

        while (!ct.IsCancellationRequested)
        {
            // Fixed cadence by default; when a backoff cap is configured, sustained failures
            // stretch the delay (capped exponential) and a success snaps it back to Interval.
            var delay = _backoffCap is null
                ? state.Interval
                : ConnectionBackoff.ComputeDelay(state.Interval, consecutiveFailures, _backoffCap.Value);
            try { await Task.Delay(delay, ct).ConfigureAwait(false); }
            catch (OperationCanceledException) { return; }
            // Defensive: the CTS may be disposed by Unsubscribe/DisposeAsync between the
            // cancellation check above and the Task.Delay touching the token. Treat that race
            // as a normal cancellation rather than a fatal exception.
            catch (ObjectDisposedException) { return; }

            try
            {
                await PollOnceAsync(state, forceRaise: false, ct).ConfigureAwait(false);
                consecutiveFailures = 0;
            }
            // Only teardown (the loop token) exits; a reader OCE with ct un-cancelled backs off
            // like any other failure instead of killing the loop (STAB-14 engine guard).
            catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; }
            catch (Exception ex) when (!IsFatal(ex))
            {
                // transient poll error — loop continues (with backoff when configured); driver
                // health surface logs it via the supplied onError callback.
                consecutiveFailures++;
                ReportError(ex);
            }
        }
    }

    /// <summary>
    ///     Programmer-error / process-fatal exception classification: anything that cannot be
    ///     safely "swallowed and retry on the next tick" must escape the poll loop instead.
    /// </summary>
    private static bool IsFatal(Exception ex)
        => ex is OutOfMemoryException
            or StackOverflowException
            or AccessViolationException
            or ThreadAbortException;

    /// <summary>
    ///     Forward a caught exception to the optional <c>onError</c> callback. Defensive
    ///     against an <c>onError</c> implementation that itself throws — that would crash the
    ///     poll loop and re-introduce the silent-stall failure mode this method exists to prevent.
    /// </summary>
    private void ReportError(Exception ex)
    {
        if (_onError is null) return;
        try { _onError(ex); }
        catch { /* never let a buggy error sink stop the poll loop */ }
    }

    private async Task PollOnceAsync(SubscriptionState state, bool forceRaise, CancellationToken ct)
    {
        var snapshots = await _reader(state.TagReferences, ct).ConfigureAwait(false);

        // Validate the reader contract before indexing. A reader that
        // returns fewer snapshots than references would silently stall the subscription; surface
        // the violation immediately with a descriptive exception instead.
        if (snapshots.Count != state.TagReferences.Count)
            throw new InvalidOperationException(
                $"Reader contract violation: expected {state.TagReferences.Count} snapshots but received {snapshots.Count}. " +
                "The reader delegate must return one snapshot per input reference in input order.");

        for (var i = 0; i < state.TagReferences.Count; i++)
        {
            var tagRef = state.TagReferences[i];
            var current = snapshots[i];
            var lastSeen = state.LastValues.TryGetValue(tagRef, out var prev) ? prev : default;

            if (forceRaise || ValuesAreDifferent(lastSeen?.Value, current.Value) || lastSeen?.StatusCode != current.StatusCode)
            {
                state.LastValues[tagRef] = current;
                _onChange(state.Handle, tagRef, current);
            }
        }
    }

    /// <summary>
    ///     Returns <c>true</c> when <paramref name="previous"/> and <paramref name="current"/>
    ///     represent different values. Array values are compared structurally
    ///     (element-by-element) so that a driver producing a fresh array instance on every poll
    ///     does not trigger spurious change events when the contents are identical.
    /// </summary>
    private static bool ValuesAreDifferent(object? previous, object? current)
    {
        if (previous is Array prevArr && current is Array currArr)
            return !StructuralComparisons.StructuralEqualityComparer.Equals(prevArr, currArr);

        return !Equals(previous, current);
    }

    /// <summary>Cancel every active subscription and await all loop tasks. Idempotent.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        // Cancel all loops first so they can all start winding down in parallel.
        foreach (var state in _subscriptions.Values)
        {
            try { state.Cts.Cancel(); } catch { }
        }

        // Await every loop task before disposing CTSs, ensuring no callback fires after disposal.
        var waitTasks = _subscriptions.Values
            .Select(s => s.LoopTask ?? Task.CompletedTask)
            .ToArray();
        if (waitTasks.Length > 0)
        {
            try { await Task.WhenAll(waitTasks).WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
            catch { }
        }

        foreach (var state in _subscriptions.Values)
        {
            state.Cts.Dispose();
        }
        _subscriptions.Clear();
    }

    private sealed record SubscriptionState(
        PollSubscriptionHandle Handle,
        IReadOnlyList<string> TagReferences,
        TimeSpan Interval,
        CancellationTokenSource Cts)
    {
        /// <summary>Gets the cache of last-seen values per tag reference.</summary>
        public ConcurrentDictionary<string, DataValueSnapshot> LastValues { get; }
            = new(StringComparer.OrdinalIgnoreCase);

        /// <summary>
        ///     The background poll-loop task. Assigned immediately after creation in
        ///     <see cref="Subscribe"/>; awaited during <see cref="Unsubscribe"/> /
        ///     <see cref="DisposeAsync"/> so disposal is deterministic and no
        ///     <c>_onChange</c> callback can fire after the caller tears down the subscription.
        /// </summary>
        public Task? LoopTask { get; set; }
    }

    private sealed record PollSubscriptionHandle(long Id) : ISubscriptionHandle
    {
        /// <inheritdoc />
        public string DiagnosticId => $"poll-sub-{Id}";
    }
}
