using Akka.Actor;
using Akka.Event;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags;

namespace ZB.MOM.WW.OtOpcUa.Runtime.Historian;

/// <summary>
///     Continuous-historization engine for non-Galaxy (driver) tags. Registers interest with the
///     per-node <see cref="DependencyMuxActor"/> for the configured historized tag refs, then taps the
///     <see cref="VirtualTagActor.DependencyValueChanged"/> values the mux fans for those refs:
///     value → coerce to numeric → append to the durable <see cref="IHistorizationOutbox"/> (the crash
///     boundary) → a self-paced drain writes the outbox through <see cref="IHistorianValueWriter"/>'s
///     live-value path and acks (truncates) on success.
/// </summary>
/// <remarks>
///     <para>
///         <b>Message path (grounded against the real actors).</b> The mux fans
///         <see cref="VirtualTagActor.DependencyValueChanged"/> — which carries only
///         <c>(TagId, Value, TimestampUtc)</c> and <em>no quality</em> — to every registered subscriber.
///         The recorder reuses that existing fan-out (no change to <c>DriverHostActor</c>), so values
///         arrive Good-quality by the same convention the scripted-alarm host uses (the mux only
///         forwards driver-published values); historized samples are recorded with
///         <see cref="GoodQuality"/>.
///     </para>
///     <para>
///         <b>Numeric-only gate.</b> The SQL analog live-value write path is numeric-only, so a
///         non-coercible value (string/null/reference) is dropped and metered rather than appended.
///     </para>
///     <para>
///         <b>Non-blocking drain.</b> Appends run on the actor thread (an awaited
///         <see cref="IHistorizationOutbox.AppendAsync"/> so appends stay serialized — the durable
///         boundary completes before the next message), but the drain runs off the actor thread and
///         pipes its outcome back to <c>Self</c> (<see cref="DrainResult"/>) so the mailbox is never
///         blocked on the gateway write. A drain never throws into the actor: the drain task catches
///         everything and reports a failed <see cref="DrainResult"/>, which backs the next attempt off
///         (exponential, capped).
///     </para>
/// </remarks>
public sealed class ContinuousHistorizationRecorder : ReceiveActor, IWithTimers
{
    /// <summary>OPC-DA quality byte recorded for mux-fanned values (the mux drops quality; driver-published
    /// values are Good by the same convention the scripted-alarm host applies).
    /// <para>
    ///     Note (archreview 06/C-7): the historian gateway's <c>WriteLiveValues</c> SQL path <b>discards</b>
    ///     quality (server-managed column, 0.2.0 Contracts C-002 — see
    ///     <c>GatewayHistorianValueWriter</c>'s remarks). This constant is therefore what a future
    ///     quality-honoring gateway path <em>would</em> receive; capturing real per-node quality here will
    ///     NOT persist until the gateway honors the field.
    /// </para></summary>
    public const ushort GoodQuality = 192;

    private const string DrainTimerKey = "drain";

    /// <summary>Self-tick that triggers a drain attempt (from the timer or a fresh append).</summary>
    private sealed class DrainTick
    {
        public static readonly DrainTick Instance = new();
        private DrainTick() { }
    }

    /// <summary>Outcome of one off-thread drain pass, piped back to the actor thread.</summary>
    private sealed record DrainResult(bool Success, int Acked, int Attempted);

    /// <summary>Ask the recorder for its current counters (used by the meter/health wiring).</summary>
    public sealed class GetStatus
    {
        /// <summary>The singleton request instance.</summary>
        public static readonly GetStatus Instance = new();
        private GetStatus() { }
    }

    /// <summary>
    ///     Converge the recorder's historized-ref interest by an add/remove DELTA — sent by the
    ///     address-space applier (via <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.IHistorizedTagSubscriptionSink"/>)
    ///     after every deploy. The applier only ever sees a plan diff, so it feeds a delta; the recorder
    ///     holds the full set and re-registers it (see <see cref="OnUpdateHistorizedRefs"/>). Each ref
    ///     carries both its mux key (<see cref="HistorizedTagRef.MuxRef"/>, the driver ref the mux fans by)
    ///     and the resolved historian name (<see cref="HistorizedTagRef.HistorianName"/>,
    ///     override-or-FullName — the same name the EnsureTags provisioning hook ensures).
    /// </summary>
    /// <param name="Added">Refs newly historized by this deploy.</param>
    /// <param name="Removed">Refs no longer historized by this deploy.</param>
    public sealed record UpdateHistorizedRefs(IReadOnlyList<HistorizedTagRef> Added, IReadOnlyList<HistorizedTagRef> Removed);

    /// <summary>A point-in-time snapshot of the recorder's counters.</summary>
    /// <param name="QueuedDepth">Un-acked entries currently held in the durable outbox.</param>
    /// <param name="TotalRecorded">Lifetime count of values appended to the outbox.</param>
    /// <param name="DroppedNonNumeric">Lifetime count of values dropped for not being numeric-coercible.</param>
    /// <param name="OutboxDropped">Lifetime count of entries the outbox dropped on capacity overflow.</param>
    /// <param name="OutboxAppendFailures">Lifetime count of durable-boundary append failures (the value
    /// was dropped, not recorded, and the actor stayed alive rather than restart-looping).</param>
    /// <param name="LastDrainSucceeded">Whether the most recent drain pass acked cleanly.</param>
    public sealed record RecorderStatus(
        int QueuedDepth,
        long TotalRecorded,
        long DroppedNonNumeric,
        long OutboxDropped,
        long OutboxAppendFailures,
        bool LastDrainSucceeded);

    private readonly IActorRef _dependencyMux;
    private readonly IHistorianValueWriter _writer;
    private readonly IHistorizationOutbox _outbox;

    /// <summary>The tracked historized tags, keyed by mux ref (<see cref="HistorizedTagRef.MuxRef"/> — the
    /// driver ref the mux fans by, and the key mux interest is registered under) → the SET of historian
    /// names that ref's value is written under (<see cref="HistorizedTagRef.HistorianName"/>). It is a SET,
    /// not a single name, because one driver ref can back SEVERAL historized equipment tags via aliasing
    /// (identical machines sharing a register), each with its own <c>HistorianTagname</c> — the mux fans
    /// one value per ref and every aliased historian tag must receive it. In the common case the set holds
    /// a single name equal to the mux ref (no override, no alias).</summary>
    private readonly Dictionary<string, HashSet<string>> _refMap;
    private readonly int _drainBatchSize;
    private readonly TimeSpan _drainInterval;
    private readonly TimeSpan _minBackoff;
    private readonly TimeSpan _maxBackoff;
    private readonly ILoggingAdapter _log = Context.GetLogger();
    private readonly CancellationTokenSource _lifetime = new();

    private bool _draining;
    private TimeSpan _currentBackoff;
    private DateTime _nextAllowedDrainUtc = DateTime.MinValue;
    private long _totalRecorded;
    private long _droppedNonNumeric;
    private long _outboxAppendFailures;
    private bool _lastDrainSucceeded = true;

    /// <summary>Gets or sets the timer scheduler (set by Akka via <see cref="IWithTimers"/>).</summary>
    public ITimerScheduler Timers { get; set; } = null!;

    /// <summary>Builds props for the recorder.</summary>
    /// <param name="dependencyMux">The per-node dependency mux to register historized-ref interest with.</param>
    /// <param name="writer">The live-value write seam the drain pushes batches through.</param>
    /// <param name="outbox">The durable FIFO outbox values are appended to before they are written.</param>
    /// <param name="historizedRefs">The fully-qualified tag refs to historize.</param>
    /// <param name="drainBatchSize">Max entries peeked + written per drain pass (must be positive).</param>
    /// <param name="drainInterval">Steady drain cadence; also the post-success reschedule.</param>
    /// <param name="minBackoff">Initial retry backoff after a failed drain.</param>
    /// <param name="maxBackoff">Cap on the exponential retry backoff.</param>
    /// <returns>Props for the actor.</returns>
    public static Props Props(
        IActorRef dependencyMux,
        IHistorianValueWriter writer,
        IHistorizationOutbox outbox,
        IReadOnlyList<string> historizedRefs,
        int drainBatchSize = 64,
        TimeSpan? drainInterval = null,
        TimeSpan? minBackoff = null,
        TimeSpan? maxBackoff = null) =>
        Akka.Actor.Props.Create(() => new ContinuousHistorizationRecorder(
            dependencyMux, writer, outbox, historizedRefs, drainBatchSize, drainInterval, minBackoff, maxBackoff));

    /// <summary>Initializes a new instance of the <see cref="ContinuousHistorizationRecorder"/> class.</summary>
    /// <param name="dependencyMux">The per-node dependency mux to register historized-ref interest with.</param>
    /// <param name="writer">The live-value write seam the drain pushes batches through.</param>
    /// <param name="outbox">The durable FIFO outbox values are appended to before they are written.</param>
    /// <param name="historizedRefs">The fully-qualified tag refs to historize.</param>
    /// <param name="drainBatchSize">Max entries peeked + written per drain pass (must be positive).</param>
    /// <param name="drainInterval">Steady drain cadence; also the post-success reschedule.</param>
    /// <param name="minBackoff">Initial retry backoff after a failed drain.</param>
    /// <param name="maxBackoff">Cap on the exponential retry backoff.</param>
    public ContinuousHistorizationRecorder(
        IActorRef dependencyMux,
        IHistorianValueWriter writer,
        IHistorizationOutbox outbox,
        IReadOnlyList<string> historizedRefs,
        int drainBatchSize = 64,
        TimeSpan? drainInterval = null,
        TimeSpan? minBackoff = null,
        TimeSpan? maxBackoff = null)
    {
        _dependencyMux = dependencyMux ?? throw new ArgumentNullException(nameof(dependencyMux));
        _writer = writer ?? throw new ArgumentNullException(nameof(writer));
        _outbox = outbox ?? throw new ArgumentNullException(nameof(outbox));
        ArgumentNullException.ThrowIfNull(historizedRefs);
        // The ctor seed is the no-override identity (mux ref == historian name); production seeds an EMPTY
        // set and converges via UpdateHistorizedRefs on each deploy (which carries diverging override pairs).
        _refMap = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal);
        foreach (string r in historizedRefs)
        {
            AddRef(r, r);
        }

        _drainBatchSize = drainBatchSize > 0 ? drainBatchSize : 64;
        _drainInterval = drainInterval is { } di && di > TimeSpan.Zero ? di : TimeSpan.FromSeconds(2);
        _minBackoff = minBackoff is { } mb && mb > TimeSpan.Zero ? mb : TimeSpan.FromSeconds(1);
        _maxBackoff = maxBackoff is { } xb && xb > TimeSpan.Zero ? xb : TimeSpan.FromSeconds(30);
        _currentBackoff = _minBackoff;

        ReceiveAsync<VirtualTagActor.DependencyValueChanged>(OnValueChangedAsync);
        Receive<UpdateHistorizedRefs>(OnUpdateHistorizedRefs);
        Receive<DrainTick>(_ => OnDrainTick());
        Receive<DrainResult>(OnDrainResult);
        ReceiveAsync<GetStatus>(async _ =>
        {
            // Capture Sender before the await: although Akka restores the actor context across awaits,
            // capturing first is the robust idiom (Sender after an await is brittle).
            IActorRef replyTo = Sender;
            RecorderStatus status = await BuildStatusAsync().ConfigureAwait(false);
            replyTo.Tell(status);
        });
    }

    /// <inheritdoc />
    protected override void PreStart()
    {
        // Register interest by mux ref (the key the mux fans DependencyValueChanged by) so it fans those
        // tags' values to us. Historian-name overrides are tracked separately for the write side.
        _dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_refMap.Keys.ToList(), Self));
        // Seed the steady drain cadence; appends also nudge a prompt drain (see OnValueChangedAsync).
        Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, _drainInterval);
        base.PreStart();
    }

    /// <inheritdoc />
    protected override void PostStop()
    {
        // Drop our mux interest eagerly (mirrors VirtualTagActor.PostStop) so the mux stops fanning to
        // us immediately, closing the dead-letter window between this stop and the mux's Terminated.
        _dependencyMux.Tell(new DependencyMuxActor.UnregisterInterest(Self));
        _lifetime.Cancel();
        _lifetime.Dispose();
        base.PostStop();
    }

    private async Task OnValueChangedAsync(VirtualTagActor.DependencyValueChanged msg)
    {
        // The mux fans values keyed by the driver ref (msg.TagId == the mux ref). Only historize refs we
        // registered interest for, and resolve the HISTORIAN NAME(s) to write under — which differ from the
        // mux ref under a HistorianTagname override, and there can be MORE THAN ONE when several historized
        // equipment tags alias the same driver ref. (The mux already scopes to our registered refs; this is
        // also the defensive filter.)
        if (!_refMap.TryGetValue(msg.TagId, out HashSet<string>? historianNames) || historianNames.Count == 0)
        {
            return;
        }

        if (!TryCoerceToDouble(msg.Value, out double numeric))
        {
            // The SQL analog live-value path is numeric-only — drop non-coercible values (string/null/
            // reference) and meter the drop. No value content is logged.
            _droppedNonNumeric++;
            _log.Debug("ContinuousHistorization: dropped non-numeric value for a historized ref.");
            return;
        }

        var timestampUtc = DateTime.SpecifyKind(msg.TimestampUtc, DateTimeKind.Utc);

        // One outbox entry PER historian name: a single mux fan-out must reach every aliased historized tag
        // (each its own historian name), not just one — collapsing to one name here would silently drop the
        // rest. Snapshot the names first: although the mailbox is suspended across the awaits below (so the
        // set cannot be mutated concurrently), iterating a snapshot is robust and clearer.
        foreach (string historianName in historianNames.ToList())
        {
            // Record under the historian name (override-or-FullName), NOT the mux ref — the outbox entry +
            // the drain's WriteLiveValues target the historian tag the EnsureTags hook provisioned.
            var entry = new HistorizationOutboxEntry(
                Guid.NewGuid(),
                historianName,
                numeric,
                GoodQuality,
                timestampUtc);

            // Durable boundary: append (awaited so appends stay serialized) BEFORE the value is considered
            // captured. The outbox drops the oldest entry on capacity overflow and tracks DroppedCount.
            try
            {
                await _outbox.AppendAsync(entry, _lifetime.Token).ConfigureAwait(false);
            }
            catch (OperationCanceledException) when (_lifetime.IsCancellationRequested)
            {
                // Normal shutdown raced the append — not a fault. Drop quietly (abandon the rest too).
                return;
            }
            catch (Exception ex)
            {
                // A durable-boundary failure (e.g. a PerEntry fsync hitting disk-full / I/O error) must
                // NEVER propagate out of the handler — that would trip Akka supervision into a restart, and
                // under a persistent disk fault the actor would restart-loop (re-register → next value →
                // append fails → restart → …). Mirror the drain path's catch-all: meter the failure
                // (category only, no value content), drop THIS name's value, and continue with the rest.
                _outboxAppendFailures++;
                _log.Warning("ContinuousHistorization: outbox append failed ({Exception}); value dropped.",
                    ex.GetType().Name);
                continue;
            }

            _totalRecorded++;
        }

        // Nudge a prompt drain attempt; the DrainTick handler de-dups (already draining) and honours
        // any active backoff window, so this never bypasses a failure cooldown.
        Self.Tell(DrainTick.Instance);
    }

    /// <summary>
    ///     Converge the tracked historized-ref set by the supplied add/remove delta, then — only when
    ///     the set actually changed — re-register interest with the mux so its fan-out matches exactly
    ///     the currently-historized refs.
    ///     <para>
    ///         <b>Convergence (grounded against <see cref="DependencyMuxActor"/>).</b> The mux's
    ///         <see cref="DependencyMuxActor.RegisterInterest"/> is a full-REPLACE (it drops the
    ///         subscriber's prior ref set and installs the new one), and its
    ///         <see cref="DependencyMuxActor.UnregisterInterest"/> drops ALL of a subscriber's interest
    ///         (no per-ref form). So a single <c>RegisterInterest</c> carrying the full tracked set
    ///         converges the mux to exactly that set in one message (added refs become fanned, removed
    ///         refs stop), and an empty set is converged with one <c>UnregisterInterest</c>. The delta is
    ///         applied removed-then-added so a ref that appears in BOTH (one tag dropped it while another
    ///         adopted it in the same deploy) ends up registered.
    ///     </para>
    ///     <para>
    ///         <b>Idempotent.</b> A delta that produces no net change to the tracked set sends NOTHING to
    ///         the mux — no spurious register/unregister churn.
    ///     </para>
    /// </summary>
    private void OnUpdateHistorizedRefs(UpdateHistorizedRefs msg)
    {
        // Snapshot the registered mux key-set BEFORE applying the delta, so we re-register only when the
        // set the mux fans by actually changes (an override-only rename updates the WRITE target but not
        // which refs the mux fans — no mux churn for those).
        var beforeKeys = new HashSet<string>(_refMap.Keys, StringComparer.Ordinal);

        // Apply removed-then-added so a ref present in BOTH (a HistorianTagname override changed while the
        // mux ref / FullName stayed the same) ends mapped to its NEW historian name. Each (muxRef, name)
        // pair is removed/added INDIVIDUALLY from the per-ref name set, so aliased tags sharing a mux ref
        // are converged independently (removing one alias's name leaves the others fanning).
        foreach (HistorizedTagRef r in msg.Removed)
        {
            if (_refMap.TryGetValue(r.MuxRef, out HashSet<string>? names))
            {
                names.Remove(r.HistorianName);
                if (names.Count == 0)
                {
                    _refMap.Remove(r.MuxRef);
                }
            }
        }

        foreach (HistorizedTagRef r in msg.Added)
        {
            AddRef(r.MuxRef, r.HistorianName);
        }

        if (_refMap.Keys.ToHashSet(StringComparer.Ordinal).SetEquals(beforeKeys))
        {
            // Mux key-set unchanged (no-op delta, an override-only rename, or an alias add/remove that
            // leaves the ref still fanned) — the per-ref name sets (write targets) are already updated;
            // stay idempotent at the mux (no register/unregister churn).
            return;
        }

        if (_refMap.Count == 0)
        {
            // The mux has no per-ref unregister; drop ALL interest in one message.
            _dependencyMux.Tell(new DependencyMuxActor.UnregisterInterest(Self));
        }
        else
        {
            // RegisterInterest REPLACES the prior set at the mux, so one message (carrying the mux keys)
            // converges it exactly.
            _dependencyMux.Tell(new DependencyMuxActor.RegisterInterest(_refMap.Keys.ToList(), Self));
        }

        _log.Debug("ContinuousHistorization: historized-ref interest converged to {Count} mux ref(s).",
            _refMap.Count);
    }

    /// <summary>Adds a <c>(mux ref → historian name)</c> mapping to the tracked set, creating the per-ref
    /// name set on first use. Idempotent — adding the same pair twice is a no-op (a <see cref="HashSet{T}"/>
    /// add).</summary>
    private void AddRef(string muxRef, string historianName)
    {
        if (!_refMap.TryGetValue(muxRef, out HashSet<string>? names))
        {
            names = new HashSet<string>(StringComparer.Ordinal);
            _refMap[muxRef] = names;
        }

        names.Add(historianName);
    }

    private void OnDrainTick()
    {
        if (_draining)
        {
            // A drain is already in flight; it reschedules itself on completion.
            return;
        }

        if (DateTime.UtcNow < _nextAllowedDrainUtc)
        {
            // Inside a post-failure backoff cooldown — the scheduled timer will fire the retry.
            return;
        }

        _draining = true;
        DrainOnceAsync(_lifetime.Token)
            .PipeTo(Self, success: result => result, failure: ToFailedDrain);
    }

    // Defensive failure mapper: DrainOnceAsync catches its own exceptions, so this only fires if the
    // PipeTo plumbing itself faults. Map to a failed drain so the actor backs off rather than dies.
    private static object ToFailedDrain(Exception _) => new DrainResult(false, 0, 0);

    private async Task<DrainResult> DrainOnceAsync(CancellationToken ct)
    {
        try
        {
            IReadOnlyList<HistorizationOutboxEntry> batch = await _outbox.PeekBatchAsync(_drainBatchSize, ct)
                .ConfigureAwait(false);
            if (batch.Count == 0)
            {
                return new DrainResult(true, 0, 0);
            }

            // Write per-tag batches. The outbox's RemoveAsync truncates the FIFO head THROUGH the acked
            // id, so partial per-tag acking could drop un-written older entries; ack all-or-nothing —
            // only when every tag's batch wrote do we truncate the whole peeked prefix (one commit).
            var allOk = true;
            foreach (IGrouping<string, HistorizationOutboxEntry> group in
                     batch.GroupBy(e => e.Tag, StringComparer.Ordinal))
            {
                var values = group
                    .Select(e => new HistorizationValue(e.TimestampUtc, e.NumericValue, e.Quality))
                    .ToList();

                bool ok;
                try
                {
                    ok = await _writer.WriteLiveValuesAsync(group.Key, values, ct).ConfigureAwait(false);
                }
                catch
                {
                    // The writer is non-throwing by contract; guard defensively so one tag's fault
                    // cannot fail the whole drain task.
                    ok = false;
                }

                if (!ok)
                {
                    allOk = false;
                }
            }

            if (!allOk)
            {
                // Leave the entire peeked prefix queued for the next (backed-off) attempt.
                return new DrainResult(false, 0, batch.Count);
            }

            // Ack the whole prefix by truncating through the last entry (FIFO head advance + commit).
            await _outbox.RemoveAsync(batch[^1].Id, ct).ConfigureAwait(false);
            return new DrainResult(true, batch.Count, batch.Count);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            // Shutdown mid-drain: not a fault. Entries stay durable and drain next startup.
            return new DrainResult(true, 0, 0);
        }
        catch (Exception exception)
        {
            // A drain exception must NEVER kill the actor. Log the category only (no value content) and
            // report a failed pass so the next attempt backs off.
            _log.Warning("ContinuousHistorization: drain pass failed ({Exception}); will retry.",
                exception.GetType().Name);
            return new DrainResult(false, 0, 0);
        }
    }

    private void OnDrainResult(DrainResult result)
    {
        _draining = false;
        _lastDrainSucceeded = result.Success;

        if (result.Success)
        {
            // Healthy: reset backoff, clear the cooldown, and keep a steady cadence to catch stragglers
            // appended during this pass.
            _currentBackoff = _minBackoff;
            _nextAllowedDrainUtc = DateTime.MinValue;
            Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, _drainInterval);
            return;
        }

        // Failed: schedule the retry after the current backoff, then grow it (capped) for the next.
        TimeSpan delay = _currentBackoff;
        _nextAllowedDrainUtc = DateTime.UtcNow + delay;
        _currentBackoff = Min(TimeSpan.FromTicks(_currentBackoff.Ticks * 2), _maxBackoff);
        Timers.StartSingleTimer(DrainTimerKey, DrainTick.Instance, delay);
    }

    private async Task<RecorderStatus> BuildStatusAsync()
    {
        int depth = await _outbox.CountAsync(_lifetime.Token).ConfigureAwait(false);
        return new RecorderStatus(
            depth,
            _totalRecorded,
            _droppedNonNumeric,
            _outbox.DroppedCount,
            _outboxAppendFailures,
            _lastDrainSucceeded);
    }

    private static TimeSpan Min(TimeSpan a, TimeSpan b) => a < b ? a : b;

    private static bool TryCoerceToDouble(object? value, out double result)
    {
        switch (value)
        {
            case double d: result = d; return true;
            case float f: result = f; return true;
            case int i: result = i; return true;
            case long l: result = l; return true;
            case short s: result = s; return true;
            case ushort us: result = us; return true;
            case uint ui: result = ui; return true;
            case ulong ul: result = ul; return true;
            case byte b: result = b; return true;
            case sbyte sb: result = sb; return true;
            case decimal dec: result = (double)dec; return true;
            case bool bo: result = bo ? 1d : 0d; return true;   // Boolean historizes as Int1 (1/0).
            default: result = 0d; return false;                 // string / null / DateTime / reference -> dropped.
        }
    }
}
