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

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;

/// <summary>
///     The server-side resume state stored behind an opaque continuation point for a single paged
///     HistoryRead-Raw read. Captures exactly enough to continue the SAME logical read from where the
///     previous page stopped: the tagname, the original (inclusive) end of the window, the next start of
///     the window, and the tie-safe boundary skip. (Only Raw pages server-side — Processed and AtTime
///     carry no client count cap, so they are single-shot and never produce a continuation point; see
///     <see cref="HistoryPaging"/>.)
///     <para>
///         The boundary fields (<see cref="NextStartUtc"/> + <see cref="BoundarySkipCount"/>) encode a
///         tie-safe resume cursor: the next page reads from <see cref="NextStartUtc"/> INCLUSIVE and
///         then drops the first <see cref="BoundarySkipCount"/> samples whose SourceTimestamp equals
///         <see cref="NextStartUtc"/>. This guarantees that samples sharing the page-boundary timestamp
///         are neither re-returned (duplicate) nor skipped — see <see cref="HistoryPaging"/>.
///     </para>
///     <para>
///         This record carries no SDK types so the whole paging decision surface is a pure, allocation-
///         cheap value that unit tests can drive directly.
///     </para>
/// </summary>
/// <param name="Tagname">The resolved historian tagname (NOT the NodeId) to read from.</param>
/// <param name="NextStartUtc">
///     Inclusive lower bound for the next page — the boundary timestamp the previous page stopped on.
/// </param>
/// <param name="EndUtc">The original (inclusive) upper bound of the read window; unchanged across pages.</param>
/// <param name="BoundarySkipCount">
///     How many samples whose SourceTimestamp equals <see cref="NextStartUtc"/> were already returned on
///     prior pages and must be dropped from the head of the next page (tie de-dup).
/// </param>
/// <param name="NumValuesPerNode">The client's per-page cap; re-applied to every resumed page.</param>
internal sealed record HistoryContinuationState(
    string Tagname,
    DateTime NextStartUtc,
    DateTime EndUtc,
    int BoundarySkipCount,
    uint NumValuesPerNode);

/// <summary>
///     Pure server-side continuation-point paging decisions for the count-capped variable-history arms
///     (Raw / Processed). The backend (the HistorianGateway read client) does NOT page — it returns up to
///     <c>NumValuesPerNode</c> samples with a null continuation point — so paging is synthesised here,
///     time-based:
///     <list type="bullet">
///       <item>A page that returns EXACTLY the requested cap (<c>NumValuesPerNode &gt; 0</c>) MAY have
///         more behind it ⇒ emit a continuation point.</item>
///       <item>A short page (fewer than the cap) is the last page ⇒ no continuation point.</item>
///       <item><c>NumValuesPerNode == 0</c> means "all values, no limit" (OPC UA Part 11) ⇒ never page;
///         return everything in one shot.</item>
///     </list>
///     All methods are static + pure so they unit-test without a server, a session, or the SDK.
/// </summary>
internal static class HistoryPaging
{
    /// <summary>
    ///     Decide whether a just-returned page is "full" and therefore MAY be followed by more data —
    ///     the signal to emit a continuation point. A page is full when the client asked for a finite
    ///     cap (<paramref name="numValuesPerNode"/> &gt; 0) and the backend returned exactly that many
    ///     samples. A short page (or an unlimited <c>0</c> request) is terminal.
    /// </summary>
    /// <param name="returnedCount">The number of samples the backend returned for this page.</param>
    /// <param name="numValuesPerNode">The client's per-page cap; <c>0</c> means "no limit".</param>
    /// <returns><c>true</c> when a continuation point should be emitted; otherwise <c>false</c>.</returns>
    public static bool IsFullPage(int returnedCount, uint numValuesPerNode) =>
        numValuesPerNode > 0 && returnedCount >= numValuesPerNode;

    /// <summary>
    ///     Build the resume cursor (next-start + boundary skip count) from the last sample of a full
    ///     page, tie-safe against samples that share the boundary SourceTimestamp.
    ///     <para>
    ///         The next page resumes from the LAST returned sample's SourceTimestamp <em>inclusive</em>
    ///         (NOT advanced by a tick), and the returned <paramref name="boundarySkipCount"/> counts how
    ///         many samples in the page already carry that exact boundary timestamp. Resuming inclusively
    ///         + dropping that many head samples guarantees:
    ///         <list type="bullet">
    ///           <item>no sample is re-returned (the ones already emitted at the boundary are skipped), and</item>
    ///           <item>no sample is skipped (any un-emitted ties at the boundary are still read because we
    ///             start AT the boundary, not after it).</item>
    ///         </list>
    ///         A naive "+1 tick" advance would skip un-emitted ties; this carry-offset strategy does not.
    ///     </para>
    /// </summary>
    /// <param name="page">The page just returned (chronological, non-empty — guaranteed by the caller,
    ///     which only pages a full page and a full page implies <c>NumValuesPerNode &gt; 0</c> samples).</param>
    /// <param name="nextStartUtc">The boundary timestamp the next page resumes from (inclusive).</param>
    /// <param name="boundarySkipCount">How many head samples at <paramref name="nextStartUtc"/> the next
    ///     page must drop (samples already emitted at the boundary timestamp).</param>
    public static void ComputeResumeCursor(
        IReadOnlyList<DataValueSnapshot> page,
        out DateTime nextStartUtc,
        out int boundarySkipCount)
    {
        // Enforce the documented precondition at the API boundary rather than relying on the caller's guard
        // (the only caller only pages a full, non-empty page, but this is a public static helper).
        if (page.Count == 0)
            throw new ArgumentOutOfRangeException(nameof(page), "ComputeResumeCursor requires a non-empty page.");

        // The boundary is the last returned sample's SourceTimestamp. A sample whose SourceTimestamp is
        // null (Bad/unset) cannot anchor a time cursor; fall back to MinValue so the next read covers the
        // whole remaining window rather than silently dropping data — duplicates are then de-duped by the
        // skip count below.
        var last = page[^1];
        nextStartUtc = last.SourceTimestampUtc ?? DateTime.MinValue;

        // Count how many trailing samples in THIS page share the boundary timestamp — those are the ties
        // already emitted at the boundary that the next page must drop from its head.
        var skip = 0;
        for (var i = page.Count - 1; i >= 0; i--)
        {
            if ((page[i].SourceTimestampUtc ?? DateTime.MinValue) == nextStartUtc) skip++;
            else break;
        }

        boundarySkipCount = skip;
    }

    /// <summary>
    ///     Drop the first <paramref name="boundarySkipCount"/> samples of a freshly-read resumed page
    ///     whose SourceTimestamp equals the boundary <paramref name="boundaryUtc"/> — the ties already
    ///     emitted on the previous page. Samples past the boundary timestamp are always kept (only the
    ///     exact-boundary head is trimmed), so a backend that returns fewer boundary ties than expected
    ///     (data pruned between pages) still yields a correct, monotonic result.
    /// </summary>
    /// <param name="resumedPage">The page returned by the resumed backend read (chronological).</param>
    /// <param name="boundaryUtc">The boundary timestamp the resume read started at (inclusive).</param>
    /// <param name="boundarySkipCount">How many head samples at <paramref name="boundaryUtc"/> to drop.</param>
    /// <returns>The page with the already-emitted boundary ties trimmed from the head.</returns>
    public static IReadOnlyList<DataValueSnapshot> TrimBoundaryDuplicates(
        IReadOnlyList<DataValueSnapshot> resumedPage,
        DateTime boundaryUtc,
        int boundarySkipCount)
    {
        if (boundarySkipCount <= 0 || resumedPage.Count == 0) return resumedPage;

        var dropped = 0;
        var i = 0;
        while (i < resumedPage.Count
               && dropped < boundarySkipCount
               && (resumedPage[i].SourceTimestampUtc ?? DateTime.MinValue) == boundaryUtc)
        {
            i++;
            dropped++;
        }

        if (i == 0) return resumedPage;
        // Slice off the trimmed head; return a copy so the caller owns a plain list (no SDK coupling).
        var trimmed = new List<DataValueSnapshot>(resumedPage.Count - i);
        for (var j = i; j < resumedPage.Count; j++) trimmed.Add(resumedPage[j]);
        return trimmed;
    }

    /// <summary>
    ///     Page WITHIN a single oversized "tie cluster" — a set of raw samples that all share one
    ///     SourceTimestamp <paramref name="boundaryT"/> and is larger than the client's per-page cap.
    ///     <para>
    ///         The fixed-<c>(start, end, cap)</c> historian backend cannot skip/offset, so an oversized
    ///         tie cluster defeats the (timestamp, skip) resume cursor that <see cref="ComputeResumeCursor"/>
    ///         builds: every resume re-reads the first <c>cap</c> ties, the boundary-tie trim empties the
    ///         page, and the cursor never advances. To page past it, the caller over-fetches the WHOLE
    ///         cluster (a bounded <c>start == end == T</c> read) and hands it here. We then carve out the
    ///         next <paramref name="cap"/> ties starting at <paramref name="skip"/> and compute a cursor
    ///         that advances within the cluster, then steps off it when it is drained.
    ///     </para>
    ///     <para>
    ///         <b>Advance is lossless.</b> When the slice drains the cluster (<c>emitted == clusterCount</c>)
    ///         we resume from <c>T + 1 tick</c> with a fresh skip of <c>0</c>. This skips no data because no
    ///         <see cref="DateTime"/> value exists strictly between <c>T</c> and <c>T + 1 tick</c> — a tick
    ///         (100 ns) is the type's resolution — so every remaining sample in the window has a timestamp
    ///         of at least <c>T + 1 tick</c>. We do NOT resume inclusively at <c>T</c> here (the way
    ///         <see cref="ComputeResumeCursor"/> does for cross-boundary ties): we have already over-fetched
    ///         and emitted the entire <c>T</c> cluster, so resuming at <c>T</c> would needlessly re-read it.
    ///     </para>
    ///     <para>
    ///         <b>Short-page-with-CP exception.</b> A page that fully drains the cluster but is SHORTER than
    ///         <paramref name="cap"/> (the cluster had fewer than <c>cap</c> remaining ties) STILL emits a
    ///         continuation point when the window extends past <c>T</c> (<c>T + 1 tick &lt;= endUtc</c>). This
    ///         deliberately violates the usual "short page ⇒ terminal" rule (<see cref="IsFullPage"/>):
    ///         the page is short only because the cluster ran out, not because the window did, so there may
    ///         still be data after <c>T</c> that the next page must read.
    ///     </para>
    ///     <para>
    ///         <b>Self-heal.</b> If <paramref name="skip"/> meets or exceeds <paramref name="clusterCount"/>
    ///         (e.g. a stale cursor against a cluster that shrank between reads), <c>sliceStart</c> clamps to
    ///         <paramref name="clusterCount"/> and <c>sliceCount</c> is <c>0</c>; since <c>emitted</c> then
    ///         equals <paramref name="clusterCount"/>, the cursor advances/terminates rather than looping.
    ///     </para>
    /// </summary>
    /// <param name="clusterCount">The total number of ties at <paramref name="boundaryT"/> (the over-fetched cluster size).</param>
    /// <param name="skip">How many ties at <paramref name="boundaryT"/> were already emitted on prior pages.</param>
    /// <param name="cap">The client's per-page cap (<c>NumValuesPerNode</c>); must be &gt; 0 to make progress.</param>
    /// <param name="boundaryT">The single SourceTimestamp every tie in the cluster shares.</param>
    /// <param name="endUtc">The (inclusive) upper bound of the read window; unchanged across pages.</param>
    /// <param name="sliceStart">The index into the cluster the emitted slice starts at (clamped to the count).</param>
    /// <param name="sliceCount">How many ties the emitted slice contains (may be <c>0</c> on a stale-skip self-heal).</param>
    /// <param name="nextStartUtc">The next page's inclusive start: <paramref name="boundaryT"/> while the
    ///     cluster still has un-emitted ties, <c>boundaryT + 1 tick</c> once it is drained and the window
    ///     remains, or <c>null</c> when the window is exhausted (terminal — no continuation point).</param>
    /// <param name="nextSkip">The next page's boundary skip count: the running emitted-tie total while the
    ///     cluster drains, else <c>0</c> after advancing past it.</param>
    public static void SliceTieCluster(
        int clusterCount, int skip, uint cap, DateTime boundaryT, DateTime endUtc,
        out int sliceStart, out int sliceCount, out DateTime? nextStartUtc, out int nextSkip)
    {
        sliceStart = Math.Min(skip, clusterCount);
        sliceCount = Math.Min((int)cap, clusterCount - sliceStart);
        var emitted = sliceStart + sliceCount;
        if (emitted < clusterCount) { nextStartUtc = boundaryT; nextSkip = emitted; }
        else
        {
            var next = boundaryT.AddTicks(1);
            if (next <= endUtc) { nextStartUtc = next; nextSkip = 0; }
            else { nextStartUtc = null; nextSkip = 0; }
        }
    }
}
