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

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

/// <summary>
/// Thread-safe queue for MxAccess events with capacity overflow and fault tracking.
/// </summary>
/// <remarks>
///     Ownership invariant: <see cref="Enqueue"/> takes ownership of the
///     <see cref="MxEvent"/> passed to it — it stamps the worker sequence and
///     timestamp on that same instance and enqueues it directly, without
///     cloning (WRK-11). Every caller must therefore pass a freshly built
///     <see cref="MxEvent"/> that it does not retain, reuse, or mutate after the
///     call returns. All production callers (MxAccessBaseEventSink,
///     MxAccessAlarmEventSink, AlarmCommandHandler) build a new event per
///     Enqueue via the mapper and satisfy this. "Does not mutate" is the load-
///     bearing half for the post-publish value cache, which deliberately borrows
///     the enqueued event's own value/timestamp/status instances rather than
///     copying them (see <see cref="MxAccessValueCache.Set"/>) — it reads and
///     serializes them, and this invariant is what keeps that safe.
///     <para>
///         The byte-budgeted <see cref="Drain(uint, int)"/> relies on the same invariant: each
///         event's serialized size is measured once at enqueue and stored beside it, which is
///         only sound because an enqueued event is never mutated again.
///     </para>
/// </remarks>
public sealed class MxAccessEventQueue
{
    /// <summary>
    /// Default queue capacity (10,000 events).
    /// </summary>
    public const int DefaultCapacity = 10000;

    /// <summary>
    ///     Environment variable the gateway's <c>WorkerProcessLauncher</c> sets
    ///     from <c>MxGateway:Worker:EventQueueCapacity</c>. A missing,
    ///     unparseable, or out-of-range value falls back to
    ///     <see cref="DefaultCapacity"/> — a bad environment value must never
    ///     keep the session from starting.
    /// </summary>
    internal const string CapacityEnvironmentVariableName = "MXGATEWAY_EVENT_QUEUE_CAPACITY";

    /// <summary>
    ///     Floor on the resolved capacity. Mirrors the gateway-side
    ///     <c>MxGateway:Worker:EventQueueCapacity</c> minimum so a value that
    ///     slipped past startup validation still leaves the headroom the queue
    ///     needs: it has no drop policy, so an overflow faults the session.
    /// </summary>
    internal const int MinimumCapacity = 1000;

    /// <summary>
    ///     Ceiling on the resolved capacity, mirroring the gateway-side maximum.
    ///     The backing queue pre-allocates its slots, so an absurd environment
    ///     value would otherwise cost the 32-bit worker that allocation at
    ///     startup.
    /// </summary>
    internal const int MaximumCapacity = 1_000_000;

    // Extra per-event slack added to WorkerEvent.CalculateSize() when charging the byte budget in
    // Drain(maxEvents, maxTotalBytes). CalculateSize() already accounts for the event's own tag and
    // length-delimiter (the WorkerEvent wrapper serializes MxEvent as field 1, and the reply packs
    // each MxEvent as DrainEventsReply.events field 1 with the identical tag+length shape), so this
    // is a pure safety margin over an already-conservative estimate — not compensation for a missing
    // wrapper. It keeps the running total strictly ahead of the true serialized size so a rounding
    // edge can never push the packed reply past the frame maximum.
    private const int RepeatedFieldOverheadBytes = 8;

    // Every field the enqueue-time size reconstruction charges by hand — WorkerEvent.event (field
    // 1), MxEvent.worker_sequence (9) and MxEvent.worker_timestamp (10) — has a field number below
    // 16, so protobuf encodes each of their tags in a single byte. Renumbering any of them above 15
    // would widen its tag and make this estimate one byte light per field, which the
    // RepeatedFieldOverheadBytes slack absorbs — so the frame stays safe — but the constant should
    // be revisited with the .proto.
    private const int SingleByteFieldTagBytes = 1;

    private readonly int capacity;
    private readonly Queue<QueuedEvent> events;
    private readonly object syncRoot = new();

    // Wake signal for the worker's event drain loop (see WorkerPipeSession.RunEventDrainLoopAsync).
    // Capped at one permit, so it is a level ("there is something to look at"), never a count: a
    // burst of enqueues coalesces into one wake and the waiter re-drains everything that arrived.
    // Never released while holding syncRoot — the STA thread enqueues here, so its path must stay a
    // lock acquire plus a non-blocking Release. Deliberately not disposed: the queue outlives its
    // waiter, no wait handle is ever materialized (nothing touches AvailableWaitHandle), and making
    // the queue IDisposable would only add a lifecycle race between the STA's Enqueue and the
    // drain loop's wait. WorkerFrameWriter's _writeLock is held the same way.
    //
    // It must STAY a SemaphoreSlim: on .NET Framework 4.8, Release() hands an async waiter to the
    // thread pool (the waiter is a TaskNode : IThreadPoolWorkItem) rather than completing it
    // inline, so the STA thread's Enqueue never runs the drain loop's continuation. Swapping this
    // for a TaskCompletionSource — a refactor that looks cosmetic — would complete the waiter
    // inline on the caller and put the drain loop's pipe write on the apartment thread, which is a
    // real STA/COM reentrancy hazard (and stalls the message pump behind the write).
    private readonly SemaphoreSlim eventSignal = new SemaphoreSlim(initialCount: 0, maxCount: 1);

    private ulong lastEventSequence;
    private WorkerFault? fault;
    private bool faultDrained;

    /// <summary>
    /// Initializes the queue with the default capacity.
    /// </summary>
    public MxAccessEventQueue()
        : this(DefaultCapacity)
    {
    }

    /// <summary>
    /// Initializes the queue with the specified capacity.
    /// </summary>
    /// <param name="capacity">Maximum number of events the queue can hold.</param>
    public MxAccessEventQueue(int capacity)
    {
        if (capacity <= 0)
        {
            throw new ArgumentOutOfRangeException(
                nameof(capacity),
                "MXAccess event queue capacity must be greater than zero.");
        }

        this.capacity = capacity;
        events = new Queue<QueuedEvent>(capacity);
    }

    /// <summary>
    ///     Resolves the queue capacity from the launcher-provided environment
    ///     variable. A missing, unparseable, or out-of-range value falls back to
    ///     <see cref="DefaultCapacity"/> rather than throwing: running on the
    ///     default capacity is always safe, and a worker that refuses to start
    ///     over a mistyped environment variable is not.
    /// </summary>
    /// <returns>The capacity to construct the outbound event queue with.</returns>
    internal static int ResolveCapacity()
    {
        string? value = Environment.GetEnvironmentVariable(CapacityEnvironmentVariableName);
        return int.TryParse(
                value,
                NumberStyles.Integer,
                CultureInfo.InvariantCulture,
                out int capacity)
            && capacity >= MinimumCapacity
            && capacity <= MaximumCapacity
                ? capacity
                : DefaultCapacity;
    }

    /// <summary>
    /// The queue's maximum capacity.
    /// </summary>
    public int Capacity => capacity;

    /// <summary>
    /// The current number of events in the queue.
    /// </summary>
    public int Count
    {
        get
        {
            lock (syncRoot)
            {
                return events.Count;
            }
        }
    }

    /// <summary>
    /// The highest event sequence number assigned.
    /// </summary>
    public ulong LastEventSequence
    {
        get
        {
            lock (syncRoot)
            {
                return lastEventSequence;
            }
        }
    }

    /// <summary>
    /// Indicates whether the queue is in a faulted state.
    /// </summary>
    public bool IsFaulted
    {
        get
        {
            lock (syncRoot)
            {
                return fault is not null;
            }
        }
    }

    /// <summary>
    /// The current fault if the queue is faulted, or null.
    /// </summary>
    public WorkerFault? Fault
    {
        get
        {
            lock (syncRoot)
            {
                return fault?.Clone();
            }
        }
    }

    /// <summary>
    /// Enqueues an MxAccess event, assigning a sequence number and timestamp.
    /// Takes ownership of <paramref name="mxEvent"/>: the sequence and timestamp
    /// are stamped on that same instance and it is enqueued without cloning, so
    /// the caller must pass a freshly built event it does not reuse afterwards.
    /// </summary>
    /// <param name="mxEvent">Freshly built MXAccess event to enqueue; ownership transfers to the queue.</param>
    public void Enqueue(MxEvent mxEvent)
    {
        if (mxEvent is null)
        {
            throw new ArgumentNullException(nameof(mxEvent));
        }

        // Size the event here, outside syncRoot, and carry the result into the queue. The
        // byte-budgeted Drain used to call CalculateSize() on every candidate while holding the
        // lock, so a large gateway-pulled drain kept the STA's next Enqueue blocked for the whole
        // sizing walk — exactly when a burst of COM callbacks needs the lock. Measuring on the
        // enqueuing thread is safe on both counts: that thread owns this event exclusively at this
        // point, and WRK-11 (see the class remarks) makes the hand-off final — nothing mutates an
        // event after Enqueue, so a memoized size can never go stale.
        //
        // Enqueue itself stamps the worker sequence and worker timestamp, and those must stay under
        // the lock (the sequence has to be assigned in queue order), so they are excluded from this
        // measurement and charged back by CalculateWorkerEventSize with O(1) arithmetic instead of a
        // second walk of the message. Clearing them first is observationally a no-op — both are
        // overwritten unconditionally below — and it makes this measurement exactly "the event minus
        // the two stamped fields" even if a caller pre-filled them.
        //
        // CalculateSize() on a valid protobuf message does not throw, so moving it ahead of the
        // fault and overflow checks introduces no new failure before them; an event those checks
        // reject simply discards the size computed here.
        mxEvent.WorkerSequence = 0UL;
        mxEvent.WorkerTimestamp = null;
        int unstampedEventSize = mxEvent.CalculateSize();

        try
        {
            lock (syncRoot)
            {
                if (fault is not null)
                {
                    throw new InvalidOperationException("MXAccess outbound event queue is faulted.");
                }

                if (events.Count >= capacity)
                {
                    fault = CreateOverflowFault();
                    throw new MxAccessEventQueueOverflowException(capacity);
                }

                // WRK-11: stamp the sequence/timestamp on the caller's own event and
                // enqueue that same instance under the lock instead of cloning. See
                // the ownership invariant on the class summary — the caller hands the
                // event over exclusively, so a defensive Clone() here is pure
                // overhead on the hottest path.
                mxEvent.WorkerSequence = ++lastEventSequence;
                mxEvent.WorkerTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);

                WorkerEvent workerEvent = new()
                {
                    Event = mxEvent,
                };
                events.Enqueue(new QueuedEvent(
                    workerEvent,
                    CalculateWorkerEventSize(mxEvent, unstampedEventSize)));
            }
        }
        finally
        {
            // Wake the drain loop outside the lock, on every exit path: the success path has an
            // event to ship, and the overflow path has just self-recorded the fault the loop
            // reports through DrainFault(). Signalling in a finally also keeps the already-faulted
            // rejection harmless — a spurious wake only costs the waiter one empty re-drain.
            SignalWake();
        }
    }

    /// <summary>
    ///     Waits for a wake signal, the fallback timeout, or cancellation, so the worker's event
    ///     drain loop is signal-driven instead of polling. <see cref="Enqueue"/> and
    ///     <see cref="RecordFault"/> both signal, so an event arriving at an idle queue is picked
    ///     up immediately rather than at the caller's next tick, and an idle queue costs no wakeups
    ///     at all.
    /// </summary>
    /// <remarks>
    ///     The wait's outcome is deliberately not surfaced: the caller must re-drain (and re-check
    ///     <see cref="DrainFault"/>) after every wait regardless of whether a signal or the timeout
    ///     ended it, because the signal is coalesced — one pending wake can stand for any number of
    ///     enqueues. The timeout is therefore a ceiling on how long the caller may sleep, not a
    ///     poll period: it bounds how late a state change reaches the caller if some future path
    ///     mutates the queue without signalling.
    ///     <para>
    ///         Precondition: <b>at most one waiter</b>. One permit is sufficient only because the
    ///         queue has exactly one consumer (the worker's single event drain loop). A second
    ///         concurrent waiter would find no permit left for it and degrade to the fallback
    ///         timeout, so a design that needs multiple drainers must widen the signal rather than
    ///         reuse this one.
    ///     </para>
    /// </remarks>
    /// <param name="timeout">Maximum time to wait before the wait completes unsignalled.</param>
    /// <param name="cancellationToken">Token cancelling the wait.</param>
    /// <returns>A task that completes when the queue is signalled or the timeout elapses.</returns>
    public Task WaitForEventsAsync(TimeSpan timeout, CancellationToken cancellationToken)
    {
        return eventSignal.WaitAsync(timeout, cancellationToken);
    }

    /// <summary>
    /// Attempts to dequeue the next event without removing it if empty.
    /// </summary>
    /// <param name="workerEvent">The dequeued event if successful; null if queue is empty.</param>
    /// <returns><see langword="true"/> if an event was dequeued; <see langword="false"/> if the queue was empty.</returns>
    public bool TryDequeue(out WorkerEvent? workerEvent)
    {
        lock (syncRoot)
        {
            if (events.Count == 0)
            {
                workerEvent = null;
                return false;
            }

            workerEvent = events.Dequeue().Event;
            return true;
        }
    }

    /// <summary>
    /// Drains up to maxEvents from the queue; if maxEvents is 0, drains all events.
    /// </summary>
    /// <param name="maxEvents">Maximum number of events to drain; 0 means drain all.</param>
    /// <returns>The drained events, in enqueue order.</returns>
    public IReadOnlyList<WorkerEvent> Drain(uint maxEvents)
    {
        lock (syncRoot)
        {
            int drainCount = maxEvents == 0
                ? events.Count
                : Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
            if (drainCount == 0)
            {
                return Array.Empty<WorkerEvent>();
            }

            List<WorkerEvent> drained = new(drainCount);
            for (int index = 0; index < drainCount; index++)
            {
                drained.Add(events.Dequeue().Event);
            }

            return drained;
        }
    }

    /// <summary>
    /// Drains from the head while both the count cap and a byte budget allow it, so the caller can
    /// build a reply frame that is guaranteed to fit the negotiated frame maximum.
    /// </summary>
    /// <remarks>
    ///     The size decision happens inside the queue lock, so an event is dequeued only once it is
    ///     known to fit: an event that does not fit stays at the head for the next call and is never
    ///     lost (WRK-21). Per-event cost is the serialized <c>WorkerEvent</c> size measured once at
    ///     enqueue time — which already includes the event's own tag and length prefix, the same
    ///     shape the reply's <c>events</c> repeated field packs it into — plus
    ///     <see cref="RepeatedFieldOverheadBytes"/> of pure slack, so the running total stays
    ///     strictly ahead of the true serialized size and the estimate errs on the safe side. Only
    ///     the memoized number is read here: this loop no longer walks a message under the lock the
    ///     STA needs for <see cref="Enqueue"/>, so a large drain cannot stall COM callbacks.
    /// </remarks>
    /// <param name="maxEvents">Maximum number of events to drain; 0 means "no count limit".</param>
    /// <param name="maxTotalBytes">Byte budget for the drained events' estimated serialized size.</param>
    /// <returns>The drained events plus the truncation facts the caller reports to the gateway.</returns>
    public WorkerEventDrainResult Drain(uint maxEvents, int maxTotalBytes)
    {
        lock (syncRoot)
        {
            int countLimit = maxEvents == 0
                ? int.MaxValue
                : checked((int)Math.Min(maxEvents, int.MaxValue));
            List<WorkerEvent> drained = new();
            int remainingBudget = maxTotalBytes;
            bool truncatedBySize = false;
            ulong oversizedHeadSequence = 0;

            while (drained.Count < countLimit && events.Count > 0)
            {
                QueuedEvent head = events.Peek();
                int cost = head.Size + RepeatedFieldOverheadBytes;
                if (cost > remainingBudget)
                {
                    truncatedBySize = true;
                    if (cost > maxTotalBytes)
                    {
                        // The head alone cannot fit this budget, so repeating the call will not
                        // move it either. Report its sequence instead of silently stalling; the
                        // events that did fit are still returned.
                        oversizedHeadSequence = head.Event.Event?.WorkerSequence ?? 0;
                    }

                    break;
                }

                remainingBudget -= cost;
                drained.Add(events.Dequeue().Event);
            }

            return new WorkerEventDrainResult(
                drained,
                truncatedBySize,
                events.Count,
                oversizedHeadSequence);
        }
    }

    /// <summary>
    /// Records a fault if one has not already been recorded.
    /// </summary>
    /// <param name="workerFault">Worker fault to record.</param>
    public void RecordFault(WorkerFault workerFault)
    {
        if (workerFault is null)
        {
            throw new ArgumentNullException(nameof(workerFault));
        }

        lock (syncRoot)
        {
            fault ??= workerFault.Clone();
        }

        // The drain loop discovers faults by polling DrainFault() at the top of each pass, so a
        // fault recorded while it is parked must wake it — otherwise the fault would be reported
        // no sooner than the loop's fallback tick. Signalled outside the lock, as Enqueue does.
        SignalWake();
    }

    /// <summary>
    /// Returns and clears the fault so it is not reported twice.
    /// </summary>
    /// <returns>The recorded <see cref="WorkerFault"/>, or <see langword="null"/> if none was recorded or it was already drained.</returns>
    public WorkerFault? DrainFault()
    {
        lock (syncRoot)
        {
            if (fault is null || faultDrained)
            {
                return null;
            }

            faultDrained = true;
            return fault.Clone();
        }
    }

    private void SignalWake()
    {
        // Fast path: a wake is already pending and no waiter has consumed it yet, so releasing
        // again would only throw. This keeps a burst of enqueues on the STA at a single volatile
        // read once the first event has signalled.
        if (eventSignal.CurrentCount > 0)
        {
            return;
        }

        try
        {
            eventSignal.Release();
        }
        catch (SemaphoreFullException)
        {
            // A concurrent producer won the race between the check above and this Release. The
            // pending wake it published is exactly the wake this call wanted, so there is nothing
            // to do — the signal is a level, not a count.
        }
    }

    // Serialized size of the WorkerEvent wrapping mxEvent, rebuilt from the size measured before the
    // lock plus the exact cost of the two fields Enqueue stamps inside it. It mirrors the generated
    // CalculateSize() instead of calling it, because calling it again under the lock is the cost
    // this memoization exists to remove: WorkerEvent carries the single length-delimited `event`
    // field, MxEvent charges worker_sequence as tag + varint and worker_timestamp as tag +
    // length-delimited message, and both are always serialized here — the sequence is
    // pre-incremented, so it is never the proto3 default that generated code would skip, and the
    // timestamp is never null. Exactness matters: Drain must charge the same bytes it charged when
    // it sized events itself, so an unchanged budget still yields unchanged batches.
    private static int CalculateWorkerEventSize(MxEvent mxEvent, int unstampedEventSize)
    {
        int eventSize = unstampedEventSize
            + SingleByteFieldTagBytes + CodedOutputStream.ComputeUInt64Size(mxEvent.WorkerSequence)
            + SingleByteFieldTagBytes + CodedOutputStream.ComputeMessageSize(mxEvent.WorkerTimestamp);

        return SingleByteFieldTagBytes + CodedOutputStream.ComputeLengthSize(eventSize) + eventSize;
    }

    private WorkerFault CreateOverflowFault()
    {
        string message = $"MXAccess outbound event queue reached capacity {capacity}.";
        return new WorkerFault
        {
            Category = WorkerFaultCategory.QueueOverflow,
            DiagnosticMessage = message,
            ProtocolStatus = new ProtocolStatus
            {
                Code = ProtocolStatusCode.WorkerUnavailable,
                Message = message,
            },
        };
    }

    // A queued event and the serialized size measured for it at enqueue time. A struct, so carrying
    // the size costs the queue one extra field per slot rather than an allocation per event on the
    // STA's path. Nothing outside the queue sees it: every drain hands back the WorkerEvent alone,
    // exactly as before.
    private readonly struct QueuedEvent
    {
        public QueuedEvent(WorkerEvent workerEvent, int size)
        {
            Event = workerEvent;
            Size = size;
        }

        // The queued event, returned unchanged by TryDequeue and both Drain overloads.
        public WorkerEvent Event { get; }

        // Serialized size of Event, valid for the queue's whole lifetime because an enqueued event
        // is never mutated again (WRK-11).
        public int Size { get; }
    }
}
