using Opc.Ua.Server;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     Stores the server-side resume state behind an opaque OPC UA HistoryRead continuation point.
///     A continuation point is 16 opaque bytes (a fresh <see cref="Guid"/>); the store maps it to a
///     <see cref="HistoryContinuationState"/>. The seam exists so the node manager can page against the
///     SDK's per-session store in production (lifecycle + cap + cleanup owned by the SDK) while tests
///     drive a session-less in-memory store through the same code path.
/// </summary>
internal interface IHistoryContinuationStore
{
    /// <summary>
    ///     Persist <paramref name="state"/> and return the opaque continuation-point bytes a client
    ///     hands back to resume. Returns <c>null</c> when the state cannot be stored (e.g. the
    ///     session-backed store has no session on this request) — the caller then returns the page with
    ///     NO continuation point, which is spec-safe (a server may always return what it has in one shot).
    /// </summary>
    /// <param name="session">The session the read runs under, or <c>null</c> for a session-less call.</param>
    /// <param name="state">The resume state to store.</param>
    /// <returns>The opaque continuation-point bytes, or <c>null</c> when storage is unavailable.</returns>
    byte[]? Save(ISession? session, HistoryContinuationState state);

    /// <summary>
    ///     Look up and REMOVE the resume state for an inbound continuation point (a continuation point is
    ///     single-use: taking it frees the slot; a fresh point is emitted if the resumed page is also full).
    /// </summary>
    /// <param name="session">The session the read runs under, or <c>null</c> for a session-less call.</param>
    /// <param name="continuationPoint">The opaque bytes the client handed back.</param>
    /// <returns>The stored state, or <c>null</c> when the point is unknown / expired / malformed.</returns>
    HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint);

    /// <summary>
    ///     Drop the resume state for a continuation point the client asked to release
    ///     (<c>releaseContinuationPoints</c>) WITHOUT reading any data. Idempotent — releasing an unknown
    ///     point is a no-op.
    /// </summary>
    /// <param name="session">The session the read runs under, or <c>null</c> for a session-less call.</param>
    /// <param name="continuationPoint">The opaque bytes to release.</param>
    void Release(ISession? session, byte[] continuationPoint);
}

/// <summary>
///     Production <see cref="IHistoryContinuationStore"/> backed by the OPC UA SDK's per-session
///     history-continuation store (<see cref="ISession.SaveHistoryContinuationPoint"/> /
///     <see cref="ISession.RestoreHistoryContinuationPoint"/>). Using the SDK store gives us, for free:
///     <list type="bullet">
///       <item>per-session lifecycle — points are disposed when the session closes, so a client that
///         disconnects mid-page can never leak resume state;</item>
///       <item>a bounded capacity with oldest-eviction — the cap is
///         <c>ServerConfiguration.MaxHistoryContinuationPoints</c> (SDK default 100); when a session
///         exceeds it the SDK silently drops its OLDEST point, so a misbehaving client cannot grow the
///         store unboundedly. A subsequent resume of an evicted point returns
///         <c>BadContinuationPointInvalid</c> (a <see cref="TryTake"/> miss);</item>
///       <item>thread-safety — the SDK session locks internally.</item>
///     </list>
///     The continuation-point bytes are a fresh 16-byte <see cref="Guid"/>; the SDK keys its slot by that
///     Guid and round-trips the opaque bytes through the wire untouched (verified against the
///     MasterNodeManager HistoryRead path — it does not register or cap history points itself).
///     <para>
///         When a HistoryRead arrives with no session (only the in-process test path does this), there is
///         nowhere session-bound to durably store resume state across calls, so <see cref="Save"/> returns
///         <c>null</c> and the read degrades to single-shot. Tests that exercise multi-page paging inject
///         the in-memory <see cref="InMemoryHistoryContinuationStore"/> instead.
///     </para>
/// </summary>
internal sealed class SessionHistoryContinuationStore : IHistoryContinuationStore
{
    /// <inheritdoc />
    public byte[]? Save(ISession? session, HistoryContinuationState state)
    {
        if (session is null) return null;

        // A fresh Guid is the opaque point: 16 bytes, collision-free, and the SDK keys its session slot by
        // it. The SDK enforces ServerConfiguration.MaxHistoryContinuationPoints with oldest-eviction.
        var id = Guid.NewGuid();
        session.SaveHistoryContinuationPoint(id, state);
        return id.ToByteArray();
    }

    /// <inheritdoc />
    public HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint)
    {
        if (session is null) return null;
        // RestoreHistoryContinuationPoint REMOVES the slot (single-use) and returns null for an unknown /
        // malformed (non-16-byte) point — exactly the "miss ⇒ BadContinuationPointInvalid" contract.
        return session.RestoreHistoryContinuationPoint(continuationPoint) as HistoryContinuationState;
    }

    /// <inheritdoc />
    public void Release(ISession? session, byte[] continuationPoint) =>
        // Restoring removes the slot; we discard the value. Null session / unknown point ⇒ no-op.
        session?.RestoreHistoryContinuationPoint(continuationPoint);
}

/// <summary>
///     In-memory <see cref="IHistoryContinuationStore"/> independent of any OPC UA session — for the
///     session-less in-process test path, which boots a real server but invokes HistoryRead with a
///     session-less <c>OperationContext</c>. Mirrors the production store's contract: 16-byte Guid points,
///     single-use take, idempotent release, and a bounded capacity with oldest-eviction so the same cap
///     semantics are exercised.
/// </summary>
internal sealed class InMemoryHistoryContinuationStore(int capacity = 100) : IHistoryContinuationStore
{
    private readonly object _gate = new();
    private readonly Dictionary<Guid, HistoryContinuationState> _states = new();
    // Insertion order, so we can evict the OLDEST when over capacity (matches the SDK store). Eventually
    // consistent: a Guid taken/released stays in _order until the eviction loop reaches it and finds it
    // already gone from _states (a harmless no-op dequeue). _states is the source of truth for liveness;
    // _order only ever over-approximates it, so eviction never drops a LIVE entry early. Fine for a
    // bounded test double — production uses the SDK's own per-session store, not this class.
    private readonly Queue<Guid> _order = new();
    private readonly int _capacity = capacity < 1 ? 1 : capacity;

    /// <inheritdoc />
    public byte[]? Save(ISession? session, HistoryContinuationState state)
    {
        var id = Guid.NewGuid();
        lock (_gate)
        {
            while (_states.Count >= _capacity && _order.Count > 0)
            {
                var oldest = _order.Dequeue();
                _states.Remove(oldest);
            }

            _states[id] = state;
            _order.Enqueue(id);
        }

        return id.ToByteArray();
    }

    /// <inheritdoc />
    public HistoryContinuationState? TryTake(ISession? session, byte[] continuationPoint)
    {
        if (continuationPoint is null || continuationPoint.Length != 16) return null;
        var id = new Guid(continuationPoint);
        lock (_gate)
        {
            if (_states.Remove(id, out var state)) return state;
        }

        return null;
    }

    /// <inheritdoc />
    public void Release(ISession? session, byte[] continuationPoint)
    {
        if (continuationPoint is null || continuationPoint.Length != 16) return;
        var id = new Guid(continuationPoint);
        lock (_gate)
        {
            _states.Remove(id);
        }
    }
}
