using Opc.Ua;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;

/// <summary>
///     Pure unit tests for the server-side HistoryRead paging decisions
///     (<see cref="HistoryPaging"/>) and the continuation-point store
///     (<see cref="InMemoryHistoryContinuationStore"/> / round-trip + cap). No server, no session,
///     no SDK — these drive the decision surface directly so the boundary / tie / cap logic is pinned
///     independent of the (operator-driven) live wire-level round trip.
/// </summary>
public sealed class HistoryPagingTests
{
    private static DataValueSnapshot Sample(double v, DateTime src) =>
        new(v, StatusCodes.Good, src, src);

    // --- IsFullPage -----------------------------------------------------------------------------

    [Fact]
    public void IsFullPage_full_count_with_finite_cap_emits()
    {
        // Backend returned exactly the cap ⇒ may be more ⇒ page.
        HistoryPaging.IsFullPage(returnedCount: 100, numValuesPerNode: 100).ShouldBeTrue();
    }

    [Fact]
    public void IsFullPage_short_page_does_not_emit()
    {
        // Fewer than the cap ⇒ last page ⇒ no continuation point.
        HistoryPaging.IsFullPage(returnedCount: 37, numValuesPerNode: 100).ShouldBeFalse();
    }

    [Fact]
    public void IsFullPage_unlimited_request_never_emits()
    {
        // NumValuesPerNode == 0 means "all values, no limit" (OPC UA Part 11) ⇒ never page.
        HistoryPaging.IsFullPage(returnedCount: 5000, numValuesPerNode: 0).ShouldBeFalse();
    }

    [Fact]
    public void IsFullPage_over_cap_still_emits()
    {
        // A backend that ignores the cap and over-returns is still treated as "maybe more".
        HistoryPaging.IsFullPage(returnedCount: 101, numValuesPerNode: 100).ShouldBeTrue();
    }

    // --- ComputeResumeCursor --------------------------------------------------------------------

    [Fact]
    public void ComputeResumeCursor_distinct_timestamps_resumes_at_last_with_single_skip()
    {
        var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var page = new[]
        {
            Sample(1, t0),
            Sample(2, t0.AddSeconds(1)),
            Sample(3, t0.AddSeconds(2)),
        };

        HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);

        // Resume from the LAST sample's timestamp, inclusive; exactly one tie at the boundary (itself).
        nextStart.ShouldBe(t0.AddSeconds(2));
        skip.ShouldBe(1);
    }

    [Fact]
    public void ComputeResumeCursor_tied_boundary_counts_all_ties()
    {
        var t0 = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc);
        var boundary = t0.AddSeconds(5);
        var page = new[]
        {
            Sample(1, t0),
            Sample(2, boundary), // tie 1
            Sample(3, boundary), // tie 2
            Sample(4, boundary), // tie 3 (last)
        };

        HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);

        nextStart.ShouldBe(boundary);
        skip.ShouldBe(3); // all three boundary ties were emitted ⇒ next page must drop them.
    }

    [Fact]
    public void ComputeResumeCursor_null_source_timestamp_falls_back_to_min_value()
    {
        var page = new[] { new DataValueSnapshot(1.0, StatusCodes.Bad, null, DateTime.UtcNow) };

        HistoryPaging.ComputeResumeCursor(page, out var nextStart, out var skip);

        nextStart.ShouldBe(DateTime.MinValue);
        skip.ShouldBe(1);
    }

    // --- TrimBoundaryDuplicates -----------------------------------------------------------------

    [Fact]
    public void TrimBoundaryDuplicates_drops_emitted_ties_keeps_the_rest()
    {
        var b = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
        // Resumed read started AT the boundary; the first 2 ties were already emitted last page.
        var resumed = new[]
        {
            Sample(10, b),               // already-emitted tie (drop)
            Sample(11, b),               // already-emitted tie (drop)
            Sample(12, b),               // NEW un-emitted tie at the boundary (keep)
            Sample(13, b.AddSeconds(1)), // past the boundary (keep)
        };

        var trimmed = HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 2);

        trimmed.Count.ShouldBe(2);
        trimmed[0].Value.ShouldBe(12.0); // the previously-skipped tie is NOT lost.
        trimmed[1].Value.ShouldBe(13.0);
    }

    [Fact]
    public void TrimBoundaryDuplicates_zero_skip_is_identity()
    {
        var b = DateTime.UtcNow;
        var resumed = new[] { Sample(1, b), Sample(2, b.AddSeconds(1)) };
        HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 0)
            .ShouldBeSameAs(resumed);
    }

    [Fact]
    public void TrimBoundaryDuplicates_only_trims_matching_boundary_timestamps()
    {
        var b = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
        // Skip count says 3, but only the first sample matches the boundary ⇒ trim stops at the mismatch.
        var resumed = new[]
        {
            Sample(10, b),
            Sample(11, b.AddSeconds(1)),
            Sample(12, b.AddSeconds(2)),
        };

        var trimmed = HistoryPaging.TrimBoundaryDuplicates(resumed, b, boundarySkipCount: 3);

        trimmed.Count.ShouldBe(2);
        trimmed[0].Value.ShouldBe(11.0);
    }

    // --- SliceTieCluster ------------------------------------------------------------------------

    [Fact]
    public void SliceTieCluster_mid_cluster_emits_cp_at_same_timestamp()
    {
        // 10 ties at T; already emitted 2; cap 3. Slice [2,5) → next page resumes AT T, skip 5.
        var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
        var end = t.AddHours(1);

        HistoryPaging.SliceTieCluster(
            clusterCount: 10, skip: 2, cap: 3, boundaryT: t, endUtc: end,
            out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

        sliceStart.ShouldBe(2);
        sliceCount.ShouldBe(3);
        nextStartUtc.ShouldBe(t);       // still draining the cluster ⇒ resume AT T
        nextSkip.ShouldBe(5);           // 2 already emitted + 3 just emitted
    }

    [Fact]
    public void SliceTieCluster_exact_drain_advances_one_tick_when_window_remains()
    {
        // 6 ties at T; skip 3; cap 3. Slice [3,6) drains the cluster exactly ⇒ advance to T+1tick.
        var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
        var end = t.AddHours(1);

        HistoryPaging.SliceTieCluster(
            clusterCount: 6, skip: 3, cap: 3, boundaryT: t, endUtc: end,
            out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

        sliceStart.ShouldBe(3);
        sliceCount.ShouldBe(3);
        nextStartUtc.ShouldBe(t.AddTicks(1)); // cluster drained, window remains ⇒ next tick, fresh skip
        nextSkip.ShouldBe(0);
    }

    [Fact]
    public void SliceTieCluster_short_final_slice_still_emits_cp_when_window_remains()
    {
        // 5 ties at T; skip 3; cap 10. Slice [3,5) is SHORT (2 < cap) but it fully drains the cluster
        // and the window still extends past T ⇒ we MUST emit a CP to read the rest of the window even
        // though this page is short of the cap.
        var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
        var end = t.AddHours(1);

        HistoryPaging.SliceTieCluster(
            clusterCount: 5, skip: 3, cap: 10, boundaryT: t, endUtc: end,
            out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

        sliceStart.ShouldBe(3);
        sliceCount.ShouldBe(2);                // short slice
        nextStartUtc.ShouldBe(t.AddTicks(1));  // but CP emitted so the rest of the window is read
        nextSkip.ShouldBe(0);
    }

    [Fact]
    public void SliceTieCluster_drained_at_window_end_terminates()
    {
        // The cluster ends exactly AT the window end (endUtc == T). Draining it cannot advance past the
        // window ⇒ terminal (no CP).
        var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);

        HistoryPaging.SliceTieCluster(
            clusterCount: 4, skip: 0, cap: 10, boundaryT: t, endUtc: t,
            out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

        sliceStart.ShouldBe(0);
        sliceCount.ShouldBe(4);
        nextStartUtc.ShouldBeNull(); // T+1tick > endUtc ⇒ window exhausted ⇒ terminal
        nextSkip.ShouldBe(0);
    }

    [Fact]
    public void SliceTieCluster_self_heals_when_skip_exceeds_cluster()
    {
        // Defensive: a stale skip points past the (re-read, possibly shrunk) cluster. The slice is empty
        // (count 0) and, since emitted == clusterCount, the cursor advances/terminates rather than looping.
        var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);
        var end = t.AddHours(1);

        HistoryPaging.SliceTieCluster(
            clusterCount: 4, skip: 4, cap: 10, boundaryT: t, endUtc: end,
            out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

        sliceStart.ShouldBe(4);
        sliceCount.ShouldBe(0);                 // nothing left in the cluster to emit
        nextStartUtc.ShouldBe(t.AddTicks(1));   // emitted (4) == clusterCount (4) ⇒ advance, don't loop
        nextSkip.ShouldBe(0);
    }

    [Fact]
    public void SliceTieCluster_self_heals_to_terminal_at_window_end()
    {
        // Same stale-skip self-heal, but the window ends at T ⇒ advancing terminates instead of looping.
        var t = new DateTime(2026, 1, 1, 0, 0, 5, DateTimeKind.Utc);

        HistoryPaging.SliceTieCluster(
            clusterCount: 4, skip: 9, cap: 10, boundaryT: t, endUtc: t,
            out var sliceStart, out var sliceCount, out var nextStartUtc, out var nextSkip);

        sliceStart.ShouldBe(4);  // clamped to clusterCount
        sliceCount.ShouldBe(0);
        nextStartUtc.ShouldBeNull();
        nextSkip.ShouldBe(0);
    }

    // --- InMemoryHistoryContinuationStore (mirrors the production session store contract) --------

    [Fact]
    public void Store_round_trips_state_through_opaque_bytes()
    {
        var store = new InMemoryHistoryContinuationStore();
        var state = new HistoryContinuationState(
            "WW.Tag", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc),
            new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc), BoundarySkipCount: 2,
            NumValuesPerNode: 100);

        var cp = store.Save(session: null, state);

        cp.ShouldNotBeNull();
        cp!.Length.ShouldBe(16); // a 16-byte Guid, matching the production store's encoding.

        var taken = store.TryTake(session: null, cp);
        taken.ShouldBe(state);
    }

    [Fact]
    public void Store_take_is_single_use()
    {
        var store = new InMemoryHistoryContinuationStore();
        var cp = store.Save(null, RawState())!;

        store.TryTake(null, cp).ShouldNotBeNull();
        // Second take of the SAME point misses ⇒ caller surfaces BadContinuationPointInvalid.
        store.TryTake(null, cp).ShouldBeNull();
    }

    [Fact]
    public void Store_release_drops_the_point()
    {
        var store = new InMemoryHistoryContinuationStore();
        var cp = store.Save(null, RawState())!;

        store.Release(null, cp);
        store.TryTake(null, cp).ShouldBeNull();
    }

    [Fact]
    public void Store_malformed_point_is_a_miss()
    {
        var store = new InMemoryHistoryContinuationStore();
        store.TryTake(null, new byte[] { 1, 2, 3 }).ShouldBeNull(); // not 16 bytes
        store.TryTake(null, Array.Empty<byte>()).ShouldBeNull();
    }

    [Fact]
    public void Store_evicts_oldest_over_capacity()
    {
        var store = new InMemoryHistoryContinuationStore(capacity: 2);
        var cp1 = store.Save(null, RawState())!;
        var cp2 = store.Save(null, RawState())!;
        var cp3 = store.Save(null, RawState())!; // pushes cp1 out (oldest-eviction)

        store.TryTake(null, cp1).ShouldBeNull();    // evicted
        store.TryTake(null, cp2).ShouldNotBeNull(); // retained
        store.TryTake(null, cp3).ShouldNotBeNull(); // retained
    }

    private static HistoryContinuationState RawState() => new(
        "WW.Tag", DateTime.UtcNow, DateTime.UtcNow.AddHours(1),
        BoundarySkipCount: 1, NumValuesPerNode: 50);
}
