using Mbproxy.Proxy.Cache;
using Shouldly;
using Xunit;

namespace Mbproxy.Tests.Proxy.Cache;

/// <summary>
/// Phase-11 unit tests for <see cref="ResponseCache"/>. Cover the load-bearing
/// behaviours: set/get round-trip, TTL expiry, write-range invalidation, LRU bounds, LRU
/// access ordering, concurrent safety, and disposal semantics.
/// </summary>
[Trait("Category", "Unit")]
public sealed class ResponseCacheTests
{
    private static CacheEntry MakeEntry(int ttlMs, byte[]? bytes = null)
    {
        var now = DateTimeOffset.UtcNow;
        bytes ??= [0x03, 0x02, 0x04, 0xD2];
        return new CacheEntry(
            PduBytes:     bytes,
            CachedAtUtc:  now,
            ExpiresAtUtc: now.AddMilliseconds(ttlMs),
            Length:       bytes.Length,
            LastUsedTick: 0);
    }

    [Fact]
    public void SetThenGet_RoundTrips()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
        var key = new CacheKey(1, 0x03, 100, 1);
        var entry = MakeEntry(ttlMs: 5000);

        cache.Set(key, entry);
        cache.TryGet(key, out var got).ShouldBeTrue();
        got.PduBytes.ShouldBe(entry.PduBytes);
        cache.Count.ShouldBe(1);
    }

    [Fact]
    public async Task GetExpiredEntry_ReturnsFalse_AndRemoves()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
        var key = new CacheKey(1, 0x03, 100, 1);

        // 50 ms TTL; sleep past it and read.
        cache.Set(key, MakeEntry(ttlMs: 50));
        cache.Count.ShouldBe(1);

        await Task.Delay(120, TestContext.Current.CancellationToken);

        cache.TryGet(key, out _).ShouldBeFalse("expired entries must report miss");
        cache.Count.ShouldBe(0, "TryGet on an expired entry must remove it lazily");
    }

    [Fact]
    public void Invalidate_OverlappingRange_RemovesMatching()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);

        // Three entries: two overlap a write [105..115), one does not.
        var overlapA = new CacheKey(1, 0x03, 100, 10);   // [100..110) — overlaps low
        var overlapB = new CacheKey(1, 0x03, 110, 10);   // [110..120) — overlaps high
        var disjoint = new CacheKey(1, 0x03, 200, 10);   // [200..210) — disjoint

        cache.Set(overlapA, MakeEntry(ttlMs: 5000));
        cache.Set(overlapB, MakeEntry(ttlMs: 5000));
        cache.Set(disjoint, MakeEntry(ttlMs: 5000));
        cache.Count.ShouldBe(3);

        int invalidated = cache.Invalidate(unitId: 1, startAddress: 105, qty: 10);

        invalidated.ShouldBe(2, "the two overlapping entries must be invalidated");
        cache.Count.ShouldBe(1, "the disjoint entry must remain");
        cache.TryGet(disjoint, out _).ShouldBeTrue("the disjoint entry must still be retrievable");
    }

    [Fact]
    public void Invalidate_DifferentUnitId_DoesNotTouch()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
        var key = new CacheKey(UnitId: 1, Fc: 0x03, StartAddress: 100, Qty: 10);
        cache.Set(key, MakeEntry(ttlMs: 5000));

        int invalidated = cache.Invalidate(unitId: 2, startAddress: 100, qty: 10);

        invalidated.ShouldBe(0, "writes on a different unit ID must not invalidate this entry");
        cache.Count.ShouldBe(1);
    }

    [Fact]
    public void Set_AtMaxEntries_EvictsLRU()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 3, evictionIntervalMs: 5000);

        // Insert 3 entries at distinct keys.
        var k1 = new CacheKey(1, 0x03, 100, 1);
        var k2 = new CacheKey(1, 0x03, 200, 1);
        var k3 = new CacheKey(1, 0x03, 300, 1);
        cache.Set(k1, MakeEntry(5000));
        cache.Set(k2, MakeEntry(5000));
        cache.Set(k3, MakeEntry(5000));
        cache.Count.ShouldBe(3);

        // 4th insert must evict the LRU — which is k1 (the earliest insert without a hit).
        var k4 = new CacheKey(1, 0x03, 400, 1);
        cache.Set(k4, MakeEntry(5000));

        cache.Count.ShouldBe(3, "cap held at 3");
        cache.TryGet(k1, out _).ShouldBeFalse("k1 was the LRU and must have been evicted");
        cache.TryGet(k4, out _).ShouldBeTrue();
    }

    [Fact]
    public void LRU_TracksAccessOrder_AcrossGetAndSet()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 3, evictionIntervalMs: 5000);

        var k1 = new CacheKey(1, 0x03, 100, 1);
        var k2 = new CacheKey(1, 0x03, 200, 1);
        var k3 = new CacheKey(1, 0x03, 300, 1);
        cache.Set(k1, MakeEntry(5000));
        cache.Set(k2, MakeEntry(5000));
        cache.Set(k3, MakeEntry(5000));

        // Touch k1 — it becomes the most-recently-used.
        cache.TryGet(k1, out _).ShouldBeTrue();

        // The LRU should now be k2 (k3 is fresher than k2 by insertion; k1 was just touched).
        var k4 = new CacheKey(1, 0x03, 400, 1);
        cache.Set(k4, MakeEntry(5000));

        cache.TryGet(k1, out _).ShouldBeTrue("k1 was just touched — must survive");
        cache.TryGet(k2, out _).ShouldBeFalse("k2 was the LRU and must have been evicted");
        cache.TryGet(k3, out _).ShouldBeTrue();
        cache.TryGet(k4, out _).ShouldBeTrue();
    }

    [Fact]
    public async Task Concurrent_GetSet_NoDataRace()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 256, evictionIntervalMs: 5000);

        // 8 tasks, 500 ops each — overlapping reads and writes on the same key space.
        // Verifies the cache survives concurrency without exceptions and remains coherent.
        const int Tasks = 8;
        const int Ops   = 500;

        var ct = TestContext.Current.CancellationToken;
        long opsCompleted = 0;

        await Task.WhenAll(Enumerable.Range(0, Tasks).Select(t => Task.Run(() =>
        {
            for (int i = 0; i < Ops; i++)
            {
                if (ct.IsCancellationRequested) return;
                var key = new CacheKey(1, 0x03, (ushort)(i & 0xFF), 1);
                if ((i & 1) == 0)
                    cache.Set(key, MakeEntry(2000));
                else
                    cache.TryGet(key, out _);
                Interlocked.Increment(ref opsCompleted);
            }
        }, ct)));

        opsCompleted.ShouldBe((long)(Tasks * Ops), "every concurrent op must complete without exception");
        cache.Count.ShouldBeLessThanOrEqualTo(256, "cap must never be exceeded under concurrent insertion");
    }

    [Fact]
    public async Task Dispose_StopsEvictionLoop_AndDoesNotThrowOnSubsequentCalls()
    {
        var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 100);

        cache.Set(new CacheKey(1, 0x03, 100, 1), MakeEntry(ttlMs: 50));
        await Task.Delay(80, TestContext.Current.CancellationToken);

        cache.Dispose();
        cache.Dispose(); // idempotent

        // After dispose, no exception on a synchronous probe — but operations are
        // best-effort; we don't promise correct results post-dispose. The contract is:
        // disposal must not corrupt state or leak the eviction task.
    }

    [Fact]
    public void Clear_DropsAllEntries_AndReturnsCount()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);
        cache.Set(new CacheKey(1, 0x03, 100, 1), MakeEntry(5000));
        cache.Set(new CacheKey(1, 0x03, 200, 1), MakeEntry(5000));

        int dropped = cache.Clear();

        dropped.ShouldBe(2);
        cache.Count.ShouldBe(0);
        cache.ApproximateBytes.ShouldBe(0);
    }

    [Fact]
    public void ApproximateBytes_TracksSetReplaceAndInvalidate()
    {
        using var cache = new ResponseCache(maxEntriesPerPlc: 16, evictionIntervalMs: 5000);

        var k1 = new CacheKey(1, 0x03, 100, 1);
        var k2 = new CacheKey(1, 0x03, 200, 1);

        cache.Set(k1, MakeEntry(5000, bytes: new byte[10]));
        cache.Set(k2, MakeEntry(5000, bytes: new byte[20]));
        cache.ApproximateBytes.ShouldBe(30L);

        // Replace k1 with a bigger entry.
        cache.Set(k1, MakeEntry(5000, bytes: new byte[15]));
        cache.ApproximateBytes.ShouldBe(35L);

        // Invalidate k1.
        cache.Invalidate(unitId: 1, startAddress: 100, qty: 1).ShouldBe(1);
        cache.ApproximateBytes.ShouldBe(20L, "approx-bytes must decrease on invalidate");
    }
}
