// Reference: golang/nats-server/server/filestore.go:4443 (setupWriteCache)
// Reference: golang/nats-server/server/filestore.go:6148 (expireCache)
// Reference: golang/nats-server/server/filestore.go:6220 (expireCacheLocked)
//
// Tests for WriteCacheManager (Gap 1.8) — bounded write cache with TTL eviction
// and background flush inside FileStore.

using NATS.Server.JetStream.Storage;

namespace NATS.Server.JetStream.Tests.JetStream.Storage;

/// <summary>
/// Tests for <see cref="FileStore.WriteCacheManager"/>. Uses direct access to the
/// internal class where needed, and tests through the public <see cref="FileStore"/>
/// API for integration coverage.
///
/// Timing-sensitive eviction tests use <c>TrackWriteAt</c> to inject an explicit
/// past timestamp rather than sleeping, avoiding flaky timing dependencies.
/// </summary>
public sealed class WriteCacheTests : IDisposable
{
    private readonly DirectoryInfo _dir = Directory.CreateTempSubdirectory("wcache-");

    public void Dispose()
    {
        try { _dir.Delete(recursive: true); }
        catch (IOException) { /* best effort — OS may hold handles briefly */ }
        catch (UnauthorizedAccessException) { /* best effort on locked directories */ }
    }

    private FileStore CreateStore(string sub, FileStoreOptions? opts = null)
    {
        var dir = Path.Combine(_dir.FullName, sub);
        opts ??= new FileStoreOptions();
        opts.Directory = dir;
        return new FileStore(opts);
    }

    // -------------------------------------------------------------------------
    // TrackWrite / TrackedBlockCount / TotalCachedBytes
    // Go: filestore.go:4443 (setupWriteCache) — track write for a block.
    // -------------------------------------------------------------------------

    [Fact]
    public void TrackWrite_AddsSizeToEntry()
    {
        // Arrange
        using var block = MsgBlock.Create(1, Path.Combine(_dir.FullName, "blk1"), 1024 * 1024);
        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(2),
            blockLookup: id => id == 1 ? block : null);

        // Act
        manager.TrackWrite(blockId: 1, bytes: 512);
        manager.TrackWrite(blockId: 1, bytes: 256);

        // Assert
        manager.TrackedBlockCount.ShouldBe(1);
        manager.TotalCachedBytes.ShouldBe(768L);
    }

    [Fact]
    public void TrackWrite_MultipleBlocks_AccumulatesSeparately()
    {
        // Arrange
        using var block1 = MsgBlock.Create(1, Path.Combine(_dir.FullName, "blk-m1"), 1024 * 1024);
        using var block2 = MsgBlock.Create(2, Path.Combine(_dir.FullName, "blk-m2"), 1024 * 1024);
        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(2),
            blockLookup: id => id == 1 ? block1 : id == 2 ? block2 : null);

        // Act
        manager.TrackWrite(blockId: 1, bytes: 100);
        manager.TrackWrite(blockId: 2, bytes: 200);
        manager.TrackWrite(blockId: 1, bytes: 50);

        // Assert
        manager.TrackedBlockCount.ShouldBe(2);
        manager.TotalCachedBytes.ShouldBe(350L);
    }

    // -------------------------------------------------------------------------
    // EvictBlock — flush then clear for a single block
    // Go: filestore.go:4499 (flushPendingMsgsLocked on rotation).
    // -------------------------------------------------------------------------

    [Fact]
    public void EvictBlock_ClearsBlockCache()
    {
        // Arrange: write a message to populate the write cache.
        var dir = Path.Combine(_dir.FullName, "evict1");
        Directory.CreateDirectory(dir);
        using var block = MsgBlock.Create(1, dir, 1024 * 1024);
        block.Write("test.subject", ReadOnlyMemory<byte>.Empty, "hello"u8.ToArray());

        block.HasCache.ShouldBeTrue("block should have write cache after write");

        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(10),
            blockLookup: id => id == 1 ? block : null);

        manager.TrackWrite(blockId: 1, bytes: 64);

        // Act
        manager.EvictBlock(blockId: 1);

        // Assert: write cache must be cleared after eviction.
        block.HasCache.ShouldBeFalse("block cache should be cleared after EvictBlock");
        manager.TrackedBlockCount.ShouldBe(0);
        manager.TotalCachedBytes.ShouldBe(0L);
    }

    [Fact]
    public void EvictBlock_NonExistentBlock_IsNoOp()
    {
        // Arrange
        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(2),
            blockLookup: _ => null);

        // Act + Assert: should not throw for an unknown block ID
        Should.NotThrow(() => manager.EvictBlock(blockId: 99));
    }

    // -------------------------------------------------------------------------
    // TTL eviction via RunEviction
    // Go: filestore.go:6220 (expireCacheLocked) — expire idle cache after TTL.
    //
    // Uses TrackWriteAt to inject a past timestamp so TTL tests do not depend
    // on real elapsed time (no Task.Delay).
    // -------------------------------------------------------------------------

    [Fact]
    public void RunEviction_ExpiresCacheAfterTtl()
    {
        // Arrange: inject a write timestamp 5 seconds in the past so it is
        // well beyond the 2-second TTL when RunEviction fires immediately.
        var dir = Path.Combine(_dir.FullName, "ttl1");
        Directory.CreateDirectory(dir);
        using var block = MsgBlock.Create(1, dir, 1024 * 1024);
        block.Write("ttl.subject", ReadOnlyMemory<byte>.Empty, "data"u8.ToArray());
        block.HasCache.ShouldBeTrue();

        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(2),
            blockLookup: id => id == 1 ? block : null);

        // Place the entry 5 000 ms in the past — well past the 2 s TTL.
        var pastTimestamp = Environment.TickCount64 - 5_000;
        manager.TrackWriteAt(blockId: 1, bytes: 128, tickCount64Ms: pastTimestamp);

        // Act: immediately trigger eviction without sleeping
        manager.RunEviction();

        // Assert: cache cleared after TTL
        block.HasCache.ShouldBeFalse("cache should be cleared after TTL expiry");
        manager.TrackedBlockCount.ShouldBe(0);
    }

    [Fact]
    public async Task RunEviction_DoesNotExpireRecentWrites()
    {
        // Arrange: write timestamp is now (fresh), TTL is 30 s — should not evict.
        var dir = Path.Combine(_dir.FullName, "ttl2");
        Directory.CreateDirectory(dir);
        using var block = MsgBlock.Create(1, dir, 1024 * 1024);
        block.Write("ttl2.subject", ReadOnlyMemory<byte>.Empty, "data"u8.ToArray());

        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(30),
            blockLookup: id => id == 1 ? block : null);

        manager.TrackWrite(blockId: 1, bytes: 64);

        // Act: trigger eviction immediately (well before TTL)
        manager.RunEviction();

        // Assert: cache should still be intact
        block.HasCache.ShouldBeTrue("cache should remain since TTL has not expired");
        manager.TrackedBlockCount.ShouldBe(1);

        await manager.DisposeAsync();
    }

    // -------------------------------------------------------------------------
    // Size-cap eviction via RunEviction
    // Go: filestore.go:6220 (expireCacheLocked) — evict oldest when over cap.
    //
    // Uses TrackWriteAt to inject explicit timestamps, making block1 definitively
    // older than block2 without relying on Task.Delay.
    // -------------------------------------------------------------------------

    [Fact]
    public async Task RunEviction_EvictsOldestWhenOverSizeCap()
    {
        // Arrange: size cap = 300 bytes, two blocks, block1 is older.
        var dir1 = Path.Combine(_dir.FullName, "cap1");
        var dir2 = Path.Combine(_dir.FullName, "cap2");
        Directory.CreateDirectory(dir1);
        Directory.CreateDirectory(dir2);
        using var block1 = MsgBlock.Create(1, dir1, 1024 * 1024);
        using var block2 = MsgBlock.Create(2, dir2, 1024 * 1024);

        block1.Write("s1", ReadOnlyMemory<byte>.Empty, "payload-one"u8.ToArray());
        block2.Write("s2", ReadOnlyMemory<byte>.Empty, "payload-two"u8.ToArray());

        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 300,
            cacheExpiry: TimeSpan.FromSeconds(60),
            blockLookup: id => id == 1 ? block1 : id == 2 ? block2 : null);

        var now = Environment.TickCount64;
        // block1 written 10 s ago (older), block2 written now (newer).
        manager.TrackWriteAt(blockId: 1, bytes: 200, tickCount64Ms: now - 10_000);
        manager.TrackWriteAt(blockId: 2, bytes: 200, tickCount64Ms: now);

        // Total is 400 bytes — exceeds cap of 300.
        manager.TotalCachedBytes.ShouldBe(400L);

        // Act
        manager.RunEviction();

        // Assert: oldest (block1) should have been evicted to bring total <= cap.
        block1.HasCache.ShouldBeFalse("oldest block should be evicted to enforce size cap");
        manager.TotalCachedBytes.ShouldBeLessThanOrEqualTo(300L);

        await manager.DisposeAsync();
    }

    // -------------------------------------------------------------------------
    // FlushAllAsync
    // Go: filestore.go:5499 (flushPendingMsgsLocked, all blocks).
    // -------------------------------------------------------------------------

    [Fact]
    public async Task FlushAllAsync_ClearsAllTrackedBlocks()
    {
        // Arrange
        var dir1 = Path.Combine(_dir.FullName, "flush1");
        var dir2 = Path.Combine(_dir.FullName, "flush2");
        Directory.CreateDirectory(dir1);
        Directory.CreateDirectory(dir2);
        using var block1 = MsgBlock.Create(1, dir1, 1024 * 1024);
        using var block2 = MsgBlock.Create(2, dir2, 1024 * 1024);

        block1.Write("flush.a", ReadOnlyMemory<byte>.Empty, "aaa"u8.ToArray());
        block2.Write("flush.b", ReadOnlyMemory<byte>.Empty, "bbb"u8.ToArray());

        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(60),
            blockLookup: id => id == 1 ? block1 : id == 2 ? block2 : null);

        manager.TrackWrite(blockId: 1, bytes: 64);
        manager.TrackWrite(blockId: 2, bytes: 64);

        manager.TrackedBlockCount.ShouldBe(2);

        // Act
        await manager.FlushAllAsync();

        // Assert
        manager.TrackedBlockCount.ShouldBe(0);
        manager.TotalCachedBytes.ShouldBe(0L);
        block1.HasCache.ShouldBeFalse("block1 cache should be cleared after FlushAllAsync");
        block2.HasCache.ShouldBeFalse("block2 cache should be cleared after FlushAllAsync");

        await manager.DisposeAsync();
    }

    // -------------------------------------------------------------------------
    // Integration with FileStore: TrackWrite called on AppendAsync / StoreMsg
    // Go: filestore.go:6700 (writeMsgRecord) — cache populated on write.
    // -------------------------------------------------------------------------

    [Fact]
    public async Task FileStore_TracksWriteAfterAppend()
    {
        // Arrange
        await using var store = CreateStore("int-append", new FileStoreOptions
        {
            BlockSizeBytes = 1024 * 1024,
            MaxCacheSize = 64 * 1024 * 1024,
            CacheExpiry = TimeSpan.FromSeconds(60),
        });

        // Act: write a few messages
        await store.AppendAsync("foo.bar", "hello world"u8.ToArray(), default);
        await store.AppendAsync("foo.baz", "second message"u8.ToArray(), default);

        // Assert: blocks were created and messages are retrievable (cache is live).
        store.BlockCount.ShouldBeGreaterThanOrEqualTo(1);
    }

    [Fact]
    public async Task FileStore_EvictsBlockCacheOnRotation()
    {
        // Arrange: tiny block size so rotation happens quickly.
        var opts = new FileStoreOptions
        {
            BlockSizeBytes = 128,    // Forces rotation after ~2 messages
            MaxCacheSize = 64 * 1024 * 1024,
            CacheExpiry = TimeSpan.FromSeconds(60),
        };
        await using var store = CreateStore("int-rotate", opts);

        // Act: write enough to trigger rotation
        for (var i = 0; i < 10; i++)
            await store.AppendAsync($"subj.{i}", new byte[20], default);

        // Assert: multiple blocks exist and all reads still succeed
        store.BlockCount.ShouldBeGreaterThan(1);

        for (ulong seq = 1; seq <= 10; seq++)
        {
            var msg = await store.LoadAsync(seq, default);
            msg.ShouldNotBeNull($"message at seq={seq} should be recoverable after block rotation");
        }
    }

    [Fact]
    public void FileStore_StoreMsg_TracksWrite()
    {
        // Arrange
        using var store = CreateStore("int-storemsg", new FileStoreOptions
        {
            BlockSizeBytes = 1024 * 1024,
            MaxCacheSize = 64 * 1024 * 1024,
            CacheExpiry = TimeSpan.FromSeconds(60),
        });

        // Act
        var (seq, _) = store.StoreMsg("test.subject", hdr: null, msg: "payload"u8.ToArray(), ttl: 0);

        // Assert: message is retrievable (write was tracked, cache is alive)
        seq.ShouldBe(1UL);
    }

    // -------------------------------------------------------------------------
    // IAsyncDisposable: DisposeAsync flushes then stops the timer
    // -------------------------------------------------------------------------

    [Fact]
    public async Task Dispose_FlushesAndStopsBackgroundTask()
    {
        // Arrange
        var dir = Path.Combine(_dir.FullName, "dispose-test");
        Directory.CreateDirectory(dir);
        using var block = MsgBlock.Create(1, dir, 1024 * 1024);
        block.Write("d.subject", ReadOnlyMemory<byte>.Empty, "data"u8.ToArray());

        var manager = new FileStore.WriteCacheManager(
            maxCacheSizeBytes: 64 * 1024 * 1024,
            cacheExpiry: TimeSpan.FromSeconds(60),
            blockLookup: id => id == 1 ? block : null);

        manager.TrackWrite(blockId: 1, bytes: 64);

        // Act: dispose should complete within a reasonable time and clear entries
        await manager.DisposeAsync();

        // Assert
        manager.TrackedBlockCount.ShouldBe(0);
        block.HasCache.ShouldBeFalse("cache should be flushed/cleared during DisposeAsync");
    }
}
