using System;
using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.Sta;

namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;

/// <summary>
///     Per-session cache of the most recent <c>OnDataChange</c> payload for
///     each (server handle, item handle) pair. Written by the MXAccess event
///     sink as new OnDataChange callbacks arrive; read by the ReadBulk command
///     executor so it can satisfy a "current value" request from a tag that is
///     already advised without modifying the existing subscription.
/// </summary>
/// <remarks>
///     Both writers and readers run on the worker's STA thread (COM dispatches
///     events on the apartment thread; commands also execute on the STA), so
///     no internal locking is required. The class is still nominally
///     thread-safe via a single sync root in case tests drive it from a
///     non-STA thread.
/// </remarks>
public sealed class MxAccessValueCache
{
    private readonly Dictionary<long, CachedValue> entries = new();
    private readonly object syncRoot = new();

    // Set by Set() so a waiter blocked in TryWaitForUpdate wakes the moment a
    // value lands instead of after the next blind poll tick. In the live worker
    // the value is recorded from inside the waiter's own pumpStep (COM
    // dispatches OnDataChange on the STA), so the message wake is what does the
    // work there; this handle covers the cross-thread producers — the tests, and
    // any future off-STA writer.
    //
    // Single-waiter invariant: an AutoResetEvent releases exactly one waiter per
    // Set. Today the sole waiter is the STA (one worker per session, and ReadBulk
    // holds the apartment while it waits), so no wake is ever lost. A second
    // concurrent waiter would not hang — the loser still re-checks the entry
    // every fallback tick and still honors its deadline — it would just fall
    // back to 50 ms latency for that iteration.
    //
    // NOT IDisposable, and that is one invariant with the wait's use of
    // DangerousGetHandle: the cache is co-owned by MxAccessBaseEventSink and
    // MxAccessSession with no single teardown owner, so a late COM callback
    // could otherwise Set a disposed handle. StaWaitHelper deliberately does not
    // DangerousAddRef the handle; GC.KeepAlive there closes the *finalization*
    // race but not a dispose race — if this field were ever disposed while the
    // STA sat inside the wait, the OS could recycle that handle value and the
    // wait would silently attach to an unrelated object. Making these caches
    // IDisposable is therefore not a free refactor: it must add DangerousAddRef/
    // DangerousRelease around the wait in the same change. One kernel event per
    // session (one session per worker process), released by the finalizer.
    private readonly AutoResetEvent updateSignal = new(false);

    /// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <param name="mxEvent">
    ///     The protobuf MxEvent created by the event mapper, already handed to
    ///     the outbound queue. The cache borrows this event's
    ///     <c>Value</c>/<c>SourceTimestamp</c>/<c>Statuses</c> instances rather
    ///     than copying them — see the ownership contract in the method body.
    /// </param>
    public void Set(
        int serverHandle,
        int itemHandle,
        MxEvent mxEvent)
    {
        if (mxEvent is null)
        {
            throw new ArgumentNullException(nameof(mxEvent));
        }

        // Ownership contract: the cache BORROWS, it does not copy. CachedValue
        // retains the event's own Value, SourceTimestamp, and Statuses
        // instances.
        //
        // Sound because the event is write-once by the time this runs.
        // MxAccessBaseEventSink.EnqueueEvent calls eventQueue.Enqueue first —
        // which stamps WorkerSequence/WorkerTimestamp inside the queue lock —
        // and only then runs the postPublish hook that lands here; the queue's
        // ownership invariant (see MxAccessEventQueue's class remarks) forbids
        // mutating an event after it is enqueued. The producer side never
        // reuses instances either: the mapper builds a fresh MxEvent, and the
        // VariantConverter a fresh MxValue, per COM callback. The three deep
        // copies this replaced (the MxValue — recursive for an MxArray — the
        // Timestamp, and the RepeatedField container plus every MxStatusProxy
        // in it) therefore bought nothing but garbage on the worker's hottest
        // path.
        //
        // Consumers of TryGet / TryWaitForUpdate may read and serialize what
        // they get back; they must never mutate it. ReadBulk already depends on
        // that: MxAccessSession.SucceededRead puts these very instances on the
        // BulkReadResult it returns, which the worker only serializes onto the
        // IPC pipe — worker↔gateway is a process boundary, so no gateway-side
        // consumer can alias them. Mutating one would corrupt the event still
        // queued for the outbound stream AND invalidate QueuedEvent.Size, the
        // serialized size memoized at enqueue that the byte-budgeted Drain
        // charges against its budget: a message grown after enqueue could
        // overshoot the negotiated frame max and fault the session with
        // MessageTooLarge (WorkerPipeSession.FaultOnOversizedEventAsync).
        //
        // Value is always set for OnDataChange; SourceTimestamp can be null when
        // the source timestamp could not be parsed. CachedValue's parameters are
        // annotated non-null but have always accepted a runtime null for both,
        // and the read side null-checks accordingly.
        long key = CreateItemKey(serverHandle, itemHandle);
        lock (syncRoot)
        {
            ulong nextVersion = entries.TryGetValue(key, out CachedValue existing)
                ? existing.Version + 1
                : 1UL;

            entries[key] = new CachedValue(
                nextVersion,
                mxEvent.Value,
                mxEvent.Quality,
                mxEvent.SourceTimestamp,
                mxEvent.Statuses);
        }

        // Signaled outside the lock: the waiter re-takes syncRoot (through
        // TryGet) the instant it wakes, so signaling while holding it would hand
        // it guaranteed contention.
        updateSignal.Set();
    }

    /// <summary>Tries to read the most recent cached value for the handle pair.</summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <param name="value">The cached value if found.</param>
    /// <returns><see langword="true"/> if a cached value exists for the handle pair; otherwise <see langword="false"/>.</returns>
    public bool TryGet(
        int serverHandle,
        int itemHandle,
        out CachedValue value)
    {
        long key = CreateItemKey(serverHandle, itemHandle);
        lock (syncRoot)
        {
            return entries.TryGetValue(key, out value);
        }
    }

    /// <summary>
    ///     Removes the cache slot for a handle pair. The session calls this
    ///     when an item is unregistered so stale values are not served to a
    ///     subsequent ReadBulk after a tag is removed and re-added.
    /// </summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    public void Remove(
        int serverHandle,
        int itemHandle)
    {
        long key = CreateItemKey(serverHandle, itemHandle);
        lock (syncRoot)
        {
            entries.Remove(key);
        }
    }

    /// <summary>
    ///     Waits until the cache entry's version exceeds <paramref name="sinceVersion"/>
    ///     or the deadline elapses, calling <paramref name="pumpStep"/> on every
    ///     iteration so the worker's STA can dispatch the inbound MXAccess message.
    /// </summary>
    /// <remarks>
    ///     The idle part of each iteration blocks in
    ///     <see cref="StaWaitHelper.WaitForSignalOrMessages"/> rather than
    ///     <c>Thread.Sleep</c>: a sleeping STA pumps no Windows messages, so a
    ///     ReadBulk waiting on the first OnDataChange would only dispatch it at
    ///     poll-tick granularity — and it holds the apartment for up to
    ///     <c>timeout_ms</c> per tag, stalling every other COM event with it. The
    ///     Win32 wait returns the instant a message needs pumping (or the value
    ///     is recorded from another thread), so the apartment keeps dispatching
    ///     continuously for the whole wait. The per-tag timeout, the deadline
    ///     math, and both return shapes are unchanged.
    /// </remarks>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <param name="sinceVersion">Version snapshot captured before the wait.</param>
    /// <param name="deadlineUtc">Absolute UTC deadline.</param>
    /// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
    /// <param name="value">The cached value if the update was received before the deadline.</param>
    /// <param name="pollIntervalMs">Upper bound on one idle wait slice — the cadence at which <paramref name="pumpStep"/> runs when nothing wakes the wait. Default 5 ms, itself capped at <see cref="StaWaitHelper.MaxFallbackTickMilliseconds"/>.</param>
    /// <returns><see langword="true"/> if an update newer than <paramref name="sinceVersion"/> arrived before the deadline; otherwise <see langword="false"/>.</returns>
    public bool TryWaitForUpdate(
        int serverHandle,
        int itemHandle,
        ulong sinceVersion,
        DateTime deadlineUtc,
        Action pumpStep,
        out CachedValue value,
        int pollIntervalMs = 5)
    {
        if (pumpStep is null)
        {
            throw new ArgumentNullException(nameof(pumpStep));
        }

        while (true)
        {
            // Pumped unconditionally at the top of every iteration — including
            // the one that immediately follows a signaled wait, and the very
            // first before any waiting. Dispatching the message is what fills
            // the cache in the live worker, and unit tests stand in for the STA
            // by driving the cache from their fake pumpStep, so this call must
            // never be conditional on a real message queue.
            pumpStep();

            if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion)
            {
                return true;
            }

            // Same expiry test as the sleep-polled version: remaining <= zero is
            // exactly DateTime.UtcNow >= deadlineUtc. `value` keeps whatever the
            // TryGet above left in it on this path, as before.
            TimeSpan remaining = deadlineUtc - DateTime.UtcNow;
            if (remaining <= TimeSpan.Zero)
            {
                return false;
            }

            StaWaitHelper.WaitForSignalOrMessages(
                updateSignal,
                StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs));
        }
    }

    /// <summary>Returns the current version for a handle pair, or 0 if no entry exists.</summary>
    /// <param name="serverHandle">MXAccess server handle.</param>
    /// <param name="itemHandle">MXAccess item handle.</param>
    /// <returns>The current cache entry version, or 0 if no entry exists.</returns>
    public ulong CurrentVersion(
        int serverHandle,
        int itemHandle)
    {
        return TryGet(serverHandle, itemHandle, out CachedValue existing)
            ? existing.Version
            : 0UL;
    }

    private static long CreateItemKey(
        int serverHandle,
        int itemHandle)
    {
        return ((long)serverHandle << 32) | (uint)itemHandle;
    }

    /// <summary>
    ///     The most recent OnDataChange payload for a handle pair.
    ///     <see cref="Version"/> increments by one on every <see cref="Set"/>
    ///     call so the bulk read executor can detect "a new value arrived
    ///     since I started waiting".
    /// </summary>
    /// <remarks>
    ///     <para>
    ///         Borrowed references, not copies. <see cref="Value"/>,
    ///         <see cref="SourceTimestamp"/>, and <see cref="Statuses"/> are the
    ///         very instances hanging off the MxEvent that was enqueued for the
    ///         outbound stream — <see cref="Set"/> carries the full ownership
    ///         contract. Read them and serialize them; never mutate them and
    ///         never hand them to something that will. That event is write-once
    ///         from the moment it is enqueued, and its serialized size is
    ///         memoized at that point for the byte-budgeted drain.
    ///     </para>
    ///     <para>
    ///         Plain readonly struct (not a record) so this compiles under the
    ///         worker's net48 target, which lacks <c>IsExternalInit</c>.
    ///     </para>
    /// </remarks>
    public readonly struct CachedValue
    {
        /// <summary>Initializes a new cached value snapshot.</summary>
        /// <param name="version">Version counter incremented on each update.</param>
        /// <param name="value">The MXAccess value, borrowed from the enqueued event.</param>
        /// <param name="quality">The MXAccess quality code.</param>
        /// <param name="sourceTimestamp">The source timestamp of the value, borrowed from the enqueued event.</param>
        /// <param name="statuses">The MXAccess status codes, borrowed from the enqueued event.</param>
        public CachedValue(
            ulong version,
            MxValue value,
            int quality,
            Timestamp sourceTimestamp,
            RepeatedField<MxStatusProxy> statuses)
        {
            Version = version;
            Value = value;
            Quality = quality;
            SourceTimestamp = sourceTimestamp;
            Statuses = statuses;
        }

        /// <summary>Monotonic per-handle version counter.</summary>
        public ulong Version { get; }

        /// <summary>The OnDataChange event's own MxValue payload. Read-only to consumers.</summary>
        public MxValue Value { get; }

        /// <summary>Quality code from the OnDataChange event.</summary>
        public int Quality { get; }

        /// <summary>The OnDataChange event's own source timestamp. Read-only to consumers.</summary>
        public Timestamp SourceTimestamp { get; }

        /// <summary>The OnDataChange event's own MxStatusProxy collection. Read-only to consumers.</summary>
        public RepeatedField<MxStatusProxy> Statuses { get; }
    }
}
