// Copyright 2012-2025 The NATS Authors
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
// Mirrors server/dirstore_test.go tests 285–296 in the NATS server Go source.
// The Go tests use nkeys.CreateAccount() + jwt.NewAccountClaims() to generate
// real signed JWTs. Here we craft minimal fake JWT strings directly using
// Base64URL-encoded JSON payloads, since DirJwtStore only parses the "exp",
// "iat" and "jti" numeric/string claims from the payload.

using System.Security.Cryptography;
using System.Text;
using Shouldly;

namespace ZB.MOM.NatsNet.Server.Tests.Accounts;

/// <summary>
/// Unit tests for <see cref="DirJwtStore"/> expiration, limits, LRU eviction,
/// reload, TTL and notification behaviour.
/// Mirrors server/dirstore_test.go tests 285–296.
/// </summary>
[Collection("DirectoryStoreTests")]
public sealed class DirectoryStoreTests : IDisposable
{
    // -------------------------------------------------------------------------
    // Counter for unique public-key names
    // -------------------------------------------------------------------------

    private static int _counter;

    private static string NextKey() =>
        $"ACCT{Interlocked.Increment(ref _counter):D8}";

    // -------------------------------------------------------------------------
    // Temp directory management
    // -------------------------------------------------------------------------

    private readonly List<string> _tempDirs = [];

    private string MakeTempDir()
    {
        var dir = Path.Combine(Path.GetTempPath(), "dirstore_" + Path.GetRandomFileName());
        Directory.CreateDirectory(dir);
        _tempDirs.Add(dir);
        return dir;
    }

    public void Dispose()
    {
        foreach (var dir in _tempDirs)
            try { Directory.Delete(dir, recursive: true); } catch { /* best-effort */ }
    }

    // -------------------------------------------------------------------------
    // Helpers — fake JWT construction
    // -------------------------------------------------------------------------

    /// <summary>
    /// Builds a minimal fake JWT string: header.payload.signature
    /// where the payload contains "exp", "iat" and "jti" claims.
    /// </summary>
    private static string MakeFakeJwt(
        long expUnixSeconds,
        long iatUnixSeconds = 0,
        string? jti = null)
    {
        if (iatUnixSeconds == 0)
            iatUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
        jti ??= Guid.NewGuid().ToString("N");

        var payloadObj = expUnixSeconds > 0
            ? $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds},\"exp\":{expUnixSeconds}}}"
            : $"{{\"jti\":\"{jti}\",\"iat\":{iatUnixSeconds}}}";

        var headerB64   = Base64UrlEncode(Encoding.UTF8.GetBytes("{\"alg\":\"ed25519-nkey\",\"typ\":\"JWT\"}"));
        var payloadB64  = Base64UrlEncode(Encoding.UTF8.GetBytes(payloadObj));
        var sigB64      = Base64UrlEncode(new byte[64]); // dummy 64-byte signature
        return $"{headerB64}.{payloadB64}.{sigB64}";
    }

    /// <summary>
    /// Rounds a <see cref="DateTimeOffset"/> to the nearest whole second,
    /// mirroring Go's <c>time.Now().Round(time.Second)</c>.
    /// </summary>
    private static DateTimeOffset RoundToSecond(DateTimeOffset dt) =>
        dt.Millisecond >= 500
            ? new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset).AddSeconds(1)
            : new DateTimeOffset(dt.Year, dt.Month, dt.Day, dt.Hour, dt.Minute, dt.Second, dt.Offset);

    private static string Base64UrlEncode(byte[] data)
    {
        return Convert.ToBase64String(data)
            .TrimEnd('=')
            .Replace('+', '-')
            .Replace('/', '_');
    }

    /// <summary>
    /// Creates and saves a test account JWT in the store.
    /// <paramref name="expSec"/> == 0 means no expiration.
    /// Returns the saved JWT string.
    /// </summary>
    private static string CreateTestAccount(DirJwtStore store, string pubKey, int expSec)
    {
        long exp = expSec > 0
            // Round to the nearest second first (mirrors Go's time.Now().Round(time.Second).Add(...).Unix()),
            // ensuring the expiry is at a whole-second boundary and avoiding sub-second truncation races.
            ? RoundToSecond(DateTimeOffset.UtcNow).AddSeconds(expSec).ToUnixTimeSeconds()
            : 0;
        var theJwt = MakeFakeJwt(exp);
        store.SaveAcc(pubKey, theJwt);
        return theJwt;
    }

    /// <summary>
    /// Counts non-deleted .jwt files in <paramref name="dir"/> recursively.
    /// </summary>
    private static int CountJwtFiles(string dir) =>
        Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
                 .Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));

    // -------------------------------------------------------------------------
    // T:285 — TestExpiration
    // -------------------------------------------------------------------------

    [Fact] // T:285
    public async Task Expiration_ExpiredAccountIsRemovedByBackground()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(50),
            limit: 10,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        var hBegin = store.Hash();

        // Add one account that should NOT expire (100-second TTL).
        var keyNoExp = NextKey();
        CreateTestAccount(store, keyNoExp, 100);
        var hNoExp = store.Hash();
        hNoExp.ShouldNotBe(hBegin);

        // Add one account that should expire in ~1 second.
        var keyExp = NextKey();
        CreateTestAccount(store, keyExp, 1);
        CountJwtFiles(dir).ShouldBe(2);

        // Wait up to 4 s for the expired file to vanish.
        var deadline = DateTime.UtcNow.AddSeconds(4);
        while (DateTime.UtcNow < deadline)
        {
            await Task.Delay(100);
            if (CountJwtFiles(dir) == 1)
                break;
        }

        CountJwtFiles(dir).ShouldBe(1, "expired account should be removed");

        // Hash after expiry should equal hash after adding only the non-expiring key.
        var lh = store.Hash();
        lh.ShouldBe(hNoExp);
    }

    // -------------------------------------------------------------------------
    // T:286 — TestLimit
    // -------------------------------------------------------------------------

    [Fact] // T:286
    public void Limit_LruEvictsOldestEntries()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(100),
            limit: 5,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        var h = store.Hash();

        // Update the first account 10 times — should remain as 1 entry.
        var firstKey = NextKey();
        for (var i = 0; i < 10; i++)
        {
            CreateTestAccount(store, firstKey, 50);
            CountJwtFiles(dir).ShouldBe(1);
        }

        // Add 10 more new accounts — limit is 5, LRU eviction kicks in.
        for (var i = 0; i < 10; i++)
        {
            var k = NextKey();
            CreateTestAccount(store, k, i + 1); // short but non-zero expiry
            var nh = store.Hash();
            nh.ShouldNotBe(h);
            h = nh;
        }

        // After all adds, only 5 files should remain.
        CountJwtFiles(dir).ShouldBe(5);

        // The first account should have been evicted.
        File.Exists(Path.Combine(dir, firstKey + ".jwt")).ShouldBeFalse();

        // Updating the first account again should succeed (limit allows eviction).
        for (var i = 0; i < 10; i++)
        {
            CreateTestAccount(store, firstKey, 50);
            CountJwtFiles(dir).ShouldBe(5);
        }
    }

    // -------------------------------------------------------------------------
    // T:287 — TestLimitNoEvict
    // -------------------------------------------------------------------------

    [Fact] // T:287
    public async Task LimitNoEvict_StoreFullThrowsOnNewKey()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(50),
            limit: 2,
            evictOnLimit: false,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        var key1 = NextKey();
        var key2 = NextKey();
        var key3 = NextKey();

        CreateTestAccount(store, key1, 100);
        CountJwtFiles(dir).ShouldBe(1);

        // key2 expires in 1 second
        CreateTestAccount(store, key2, 1);
        CountJwtFiles(dir).ShouldBe(2);

        var hashBefore = store.Hash();

        // Attempting to add key3 should throw (limit=2, no evict).
        var exp3 = DateTimeOffset.UtcNow.AddSeconds(100).ToUnixTimeSeconds();
        var jwt3 = MakeFakeJwt(exp3);
        Should.Throw<InvalidOperationException>(() => store.SaveAcc(key3, jwt3));
        CountJwtFiles(dir).ShouldBe(2);
        File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
        File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();

        // Hash should not change after the failed add.
        store.Hash().ShouldBe(hashBefore);

        // Wait for key2 to expire.
        await Task.Delay(2200);

        // Now adding key3 should succeed.
        store.SaveAcc(key3, jwt3);
        CountJwtFiles(dir).ShouldBe(2);
        File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
        File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
    }

    // -------------------------------------------------------------------------
    // T:288 — TestLruLoad
    // -------------------------------------------------------------------------

    [Fact] // T:288
    public void LruLoad_LoadReordersLru()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(100),
            limit: 2,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        var key1 = NextKey();
        var key2 = NextKey();
        var key3 = NextKey();

        CreateTestAccount(store, key1, 10);
        CountJwtFiles(dir).ShouldBe(1);
        CreateTestAccount(store, key2, 10);
        CountJwtFiles(dir).ShouldBe(2);

        // Access key1 — makes it the most-recently-used.
        store.LoadAcc(key1);

        // Adding key3 should evict key2 (oldest), not key1.
        CreateTestAccount(store, key3, 10);
        CountJwtFiles(dir).ShouldBe(2);

        File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeTrue();
        File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();
    }

    // -------------------------------------------------------------------------
    // T:289 — TestLruVolume
    // -------------------------------------------------------------------------

    [Fact] // T:289
    public void LruVolume_ContinuousReplacementsAlwaysEvictsOldest()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(50),
            limit: 2,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        const int ReplaceCnt = 200; // must be > 2 due to the invariant
        var keys = new string[ReplaceCnt];

        keys[0] = NextKey();
        CreateTestAccount(store, keys[0], 10000);
        CountJwtFiles(dir).ShouldBe(1);

        keys[1] = NextKey();
        CreateTestAccount(store, keys[1], 10000);
        CountJwtFiles(dir).ShouldBe(2);

        for (var i = 2; i < ReplaceCnt; i++)
        {
            keys[i] = NextKey();
            CreateTestAccount(store, keys[i], 10000);
            CountJwtFiles(dir).ShouldBe(2);

            // key two positions back should have been evicted.
            File.Exists(Path.Combine(dir, keys[i - 2] + ".jwt")).ShouldBeFalse(
                $"key[{i - 2}] should be evicted after adding key[{i}]");
            // key one position back should still be present.
            File.Exists(Path.Combine(dir, keys[i - 1] + ".jwt")).ShouldBeTrue();
            // current key should be present.
            File.Exists(Path.Combine(dir, keys[i] + ".jwt")).ShouldBeTrue();
        }
    }

    // -------------------------------------------------------------------------
    // T:290 — TestLru
    // -------------------------------------------------------------------------

    [Fact] // T:290
    public async Task Lru_EvictsAndExpires()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(50),
            limit: 2,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        var key1 = NextKey();
        var key2 = NextKey();
        var key3 = NextKey();

        CreateTestAccount(store, key1, 1000);
        CountJwtFiles(dir).ShouldBe(1);
        CreateTestAccount(store, key2, 1000);
        CountJwtFiles(dir).ShouldBe(2);

        // Adding key3 should evict key1 (oldest).
        CreateTestAccount(store, key3, 1000);
        CountJwtFiles(dir).ShouldBe(2);
        File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();
        File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeTrue();

        // Update key2 → moves it to MRU. key3 becomes LRU.
        CreateTestAccount(store, key2, 1000);
        CountJwtFiles(dir).ShouldBe(2);

        // Recreate key1 (which was evicted) → evicts key3.
        CreateTestAccount(store, key1, 1); // expires in 1 s
        CountJwtFiles(dir).ShouldBe(2);
        File.Exists(Path.Combine(dir, key3 + ".jwt")).ShouldBeFalse();

        // Let key1 expire (1 s + 1 s buffer for rounding).
        await Task.Delay(2200);
        CountJwtFiles(dir).ShouldBe(1);
        File.Exists(Path.Combine(dir, key1 + ".jwt")).ShouldBeFalse();

        // Recreate key3 — no eviction needed, slot is free.
        CreateTestAccount(store, key3, 1000);
        CountJwtFiles(dir).ShouldBe(2);
    }

    // -------------------------------------------------------------------------
    // T:291 — TestReload
    // -------------------------------------------------------------------------

    [Fact] // T:291
    public void Reload_DetectsFilesAddedAndRemoved()
    {
        var dir = MakeTempDir();
        var notificationChan = new System.Collections.Concurrent.ConcurrentQueue<string>();

        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(100),
            limit: 2,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: pk => notificationChan.Enqueue(pk));

        CountJwtFiles(dir).ShouldBe(0);
        var emptyHash = new byte[32];
        store.Hash().ShouldBe(emptyHash);

        var files = new List<string>();

        // Add 5 accounts by writing to disk directly, then Reload().
        for (var i = 0; i < 5; i++)
        {
            var key  = NextKey();
            var exp  = DateTimeOffset.UtcNow.AddSeconds(10000).ToUnixTimeSeconds();
            var jwt  = MakeFakeJwt(exp);
            var path = Path.Combine(dir, key + ".jwt");
            File.WriteAllText(path, jwt);
            files.Add(path);

            store.Reload();

            // Wait briefly for notification.
            var deadline = DateTime.UtcNow.AddMilliseconds(500);
            while (notificationChan.IsEmpty && DateTime.UtcNow < deadline)
                Thread.Sleep(10);
            notificationChan.TryDequeue(out _);

            CountJwtFiles(dir).ShouldBe(i + 1);
            store.Hash().ShouldNotBe(emptyHash);

            var packed = store.Pack(-1);
            packed.Split('\n').Length.ShouldBe(i + 1);
        }

        // Now remove files one by one.
        foreach (var f in files)
        {
            var hash = store.Hash();
            hash.ShouldNotBe(emptyHash);
            File.Delete(f);
            store.Reload();
            CountJwtFiles(dir).ShouldBe(files.Count - files.IndexOf(f) - 1);
        }

        store.Hash().ShouldBe(emptyHash);
    }

    // -------------------------------------------------------------------------
    // T:292 — TestExpirationUpdate
    // -------------------------------------------------------------------------

    [Fact] // T:292
    public async Task ExpirationUpdate_UpdatingExpirationExtendsTTL()
    {
        var dir = MakeTempDir();
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(50),
            limit: 10,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: null);

        var key = NextKey();
        var h = store.Hash();

        // Save account with no expiry.
        CreateTestAccount(store, key, 0);
        var nh = store.Hash();
        nh.ShouldNotBe(h);
        h = nh;

        await Task.Delay(1500);
        CountJwtFiles(dir).ShouldBe(1); // should NOT have expired (no exp claim)

        // Save same account with 2-second expiry.
        CreateTestAccount(store, key, 2);
        nh = store.Hash();
        nh.ShouldNotBe(h);
        h = nh;

        await Task.Delay(1500);
        CountJwtFiles(dir).ShouldBe(1); // not expired yet

        // Save with no expiry again — resets expiry on that account.
        CreateTestAccount(store, key, 0);
        nh = store.Hash();
        nh.ShouldNotBe(h);
        h = nh;

        await Task.Delay(1500);
        CountJwtFiles(dir).ShouldBe(1); // still NOT expired

        // Now save with 1-second expiry.
        CreateTestAccount(store, key, 1);
        nh = store.Hash();
        nh.ShouldNotBe(h);

        await Task.Delay(1500);
        CountJwtFiles(dir).ShouldBe(0); // should be expired now

        var empty = new byte[32];
        store.Hash().ShouldBe(empty);
    }

    // -------------------------------------------------------------------------
    // T:293 — TestTTL
    // -------------------------------------------------------------------------

    [Fact] // T:293
    public async Task TTL_AccessResetsExpirationOnStore()
    {
        var dir = MakeTempDir();
        var key = NextKey();

        // TTL = 200 ms. Each access (Load or Save) should reset expiry.
        using var store = DirJwtStore.NewExpiringDirJwtStore(
            dir, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: TimeSpan.FromMilliseconds(50),
            limit: 10,
            evictOnLimit: true,
            ttl: TimeSpan.FromMilliseconds(200),
            changeNotification: null);

        CreateTestAccount(store, key, 0);
        CountJwtFiles(dir).ShouldBe(1);

        // Access every 110 ms — should prevent expiration.
        for (var i = 0; i < 4; i++)
        {
            await Task.Delay(110);
            store.LoadAcc(key); // TTL reset via Load
            CountJwtFiles(dir).ShouldBe(1);
        }

        // Stop accessing — wait for expiration.
        var deadline = DateTime.UtcNow.AddSeconds(3);
        while (DateTime.UtcNow < deadline)
        {
            await Task.Delay(50);
            if (CountJwtFiles(dir) == 0)
                return; // expired as expected
        }

        Assert.Fail("JWT should have expired by now via TTL");
    }

    // -------------------------------------------------------------------------
    // T:294 — TestRemove
    // -------------------------------------------------------------------------

    [Fact] // T:294
    public void Remove_RespectsDeleteType()
    {
        foreach (var (deleteType, expectedJwt, expectedDeleted) in new[]
        {
            (JwtDeleteType.HardDelete,    0, 0),
            (JwtDeleteType.RenameDeleted, 0, 1),
            (JwtDeleteType.NoDelete,      1, 0),
        })
        {
            var dir = MakeTempDir();
            using var store = DirJwtStore.NewExpiringDirJwtStore(
                dir, shard: false, create: false,
                deleteType: deleteType,
                expireCheck: TimeSpan.Zero,
                limit: 10,
                evictOnLimit: true,
                ttl: TimeSpan.Zero,
                changeNotification: null);

            var key = NextKey();
            CreateTestAccount(store, key, 0);
            CountJwtFiles(dir).ShouldBe(1, $"deleteType={deleteType}: should have 1 jwt before delete");

            // For HardDelete and RenameDeleted the store must allow Delete.
            // For NoDelete, Delete should throw.
            if (deleteType == JwtDeleteType.NoDelete)
            {
                Should.Throw<InvalidOperationException>(() => store.Delete(key),
                    $"deleteType={deleteType}: should throw on delete");
            }
            else
            {
                store.Delete(key);
            }

            // Count .jwt files (not .jwt.deleted).
            var jwtFiles = Directory.GetFiles(dir, "*.jwt", SearchOption.AllDirectories)
                                    .Count(f => !f.EndsWith(".jwt.deleted", StringComparison.Ordinal));
            jwtFiles.ShouldBe(expectedJwt, $"deleteType={deleteType}: unexpected jwt count");

            // Count .jwt.deleted files.
            var deletedFiles = Directory.GetFiles(dir, "*.jwt.deleted", SearchOption.AllDirectories).Length;
            deletedFiles.ShouldBe(expectedDeleted, $"deleteType={deleteType}: unexpected deleted count");
        }
    }

    // -------------------------------------------------------------------------
    // T:295 — TestNotificationOnPack
    // -------------------------------------------------------------------------

    [Fact] // T:295
    public void NotificationOnPack_MergeFiresChangedCallback()
    {
        // Pre-populate a store with 4 accounts, pack it, then Merge into new stores.
        // Each Merge should fire the change notification for every key.
        const int JwtCount = 4;
        var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)
        var dirPack = MakeTempDir();
        var keys = new string[JwtCount];
        var jwts = new string[JwtCount];

        var notifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
        using var packStore = DirJwtStore.NewExpiringDirJwtStore(
            dirPack, shard: false, create: false,
            deleteType: JwtDeleteType.NoDelete,
            expireCheck: infDur,
            limit: 0,
            evictOnLimit: true,
            ttl: TimeSpan.Zero,
            changeNotification: pk => notifications.Enqueue(pk));

        for (var i = 0; i < JwtCount; i++)
        {
            keys[i] = NextKey();
            jwts[i] = MakeFakeJwt(0); // no expiry
            packStore.SaveAcc(keys[i], jwts[i]);
        }

        // Drain initial notifications.
        var deadline = DateTime.UtcNow.AddSeconds(2);
        while (notifications.Count < JwtCount && DateTime.UtcNow < deadline)
            Thread.Sleep(10);
        while (notifications.TryDequeue(out _)) { }

        var msg  = packStore.Pack(-1);
        var hash = packStore.Hash();

        // Merge into new stores (sharded and unsharded).
        foreach (var shard in new[] { true, false, true, false })
        {
            var dirMerge = MakeTempDir();
            var mergeNotifications = new System.Collections.Concurrent.ConcurrentQueue<string>();
            using var mergeStore = DirJwtStore.NewExpiringDirJwtStore(
                dirMerge, shard: shard, create: false,
                deleteType: JwtDeleteType.NoDelete,
                expireCheck: infDur,
                limit: 0,
                evictOnLimit: true,
                ttl: TimeSpan.Zero,
                changeNotification: pk => mergeNotifications.Enqueue(pk));

            mergeStore.Merge(msg);
            CountJwtFiles(dirMerge).ShouldBe(JwtCount);

            // Hashes must match.
            packStore.Hash().ShouldBe(hash);

            // Wait for JwtCount notifications.
            deadline = DateTime.UtcNow.AddSeconds(2);
            while (mergeNotifications.Count < JwtCount && DateTime.UtcNow < deadline)
                Thread.Sleep(10);
            mergeNotifications.Count.ShouldBeGreaterThanOrEqualTo(JwtCount);

            // Double-merge should produce no extra file changes.
            while (mergeNotifications.TryDequeue(out _)) { }
            mergeStore.Merge(msg);
            CountJwtFiles(dirMerge).ShouldBe(JwtCount);
            Thread.Sleep(50);
            mergeNotifications.IsEmpty.ShouldBeTrue("no new notifications on re-merge of identical JWTs");

            msg = mergeStore.Pack(-1);
        }

        // All original JWTs can still be loaded from the last pack.
        for (var i = 0; i < JwtCount; i++)
        {
            var found = msg.Contains(keys[i] + "|" + jwts[i]);
            found.ShouldBeTrue($"key {keys[i]} should be in packed message");
        }
    }

    // -------------------------------------------------------------------------
    // T:296 — TestNotificationOnPackWalk
    // -------------------------------------------------------------------------

    [Fact] // T:296
    public void NotificationOnPackWalk_PropagatesAcrossChainOfStores()
    {
        const int StoreCnt  = 5;
        const int KeyCnt    = 50;
        const int IterCnt   = 4; // reduced from Go's 8 to keep test fast
        var infDur = TimeSpan.FromDays(49); // "effectively infinite" (Timer max ≈ 49.7 days; TimeSpan.MaxValue/2 exceeds it)

        var stores = new DirJwtStore[StoreCnt];
        var dirs   = new string[StoreCnt];
        try
        {
            for (var i = 0; i < StoreCnt; i++)
            {
                dirs[i] = MakeTempDir();
                stores[i] = DirJwtStore.NewExpiringDirJwtStore(
                    dirs[i], shard: true, create: false,
                    deleteType: JwtDeleteType.NoDelete,
                    expireCheck: infDur,
                    limit: 0,
                    evictOnLimit: true,
                    ttl: TimeSpan.Zero,
                    changeNotification: null);
            }

            for (var iter = 0; iter < IterCnt; iter++)
            {
                // Fill store[0] with KeyCnt new accounts.
                for (var j = 0; j < KeyCnt; j++)
                {
                    var k   = NextKey();
                    var jwt = MakeFakeJwt(0);
                    stores[0].SaveAcc(k, jwt);
                }

                // Propagate via PackWalk from store[n] → store[n+1].
                for (var j = 0; j < StoreCnt - 1; j++)
                {
                    stores[j].PackWalk(3, partial => stores[j + 1].Merge(partial));
                }

                // Verify all adjacent store hashes match.
                for (var j = 0; j < StoreCnt - 1; j++)
                {
                    stores[j].Hash().ShouldBe(stores[j + 1].Hash(),
                        $"stores[{j}] and stores[{j + 1}] should have matching hashes after iteration {iter}");
                }
            }
        }
        finally
        {
            foreach (var s in stores) try { s?.Dispose(); } catch { /* best-effort */ }
        }
    }

    [Fact]
    public void ValidateDirPath_ExistingDirectory_ReturnsAbsolutePath()
    {
        var dir = MakeTempDir();

        var validated = DirJwtStore.ValidateDirPath(dir);

        validated.ShouldBe(Path.GetFullPath(dir));
    }

    [Fact]
    public void ValidatePathExists_PathIsFileWhenDirectoryExpected_Throws()
    {
        var dir = MakeTempDir();
        var file = Path.Combine(dir, "token.jwt");
        File.WriteAllText(file, "jwt");

        Should.Throw<InvalidOperationException>(() => DirJwtStore.ValidatePathExists(file, dir: true));
    }

    [Fact]
    public void ExpirationTracker_HeapPrimitives_MaintainIndexAndTracking()
    {
        var tracker = new ExpirationTracker(limit: 10, evictOnLimit: true, ttl: TimeSpan.Zero);
        var a = new JwtItem("A", expiration: 10, hash: [1, 2, 3]);
        var b = new JwtItem("B", expiration: 20, hash: [4, 5, 6]);

        tracker.Push(a);
        tracker.Push(b);

        tracker.Len().ShouldBe(2);
        tracker.Less(0, 1).ShouldBeTrue();

        tracker.Swap(0, 1);
        tracker.Less(0, 1).ShouldBeFalse();

        var popped = tracker.Pop();
        popped.PublicKey.ShouldBe("A");
        tracker.IsTracked("A").ShouldBeFalse();
        tracker.IsTracked("B").ShouldBeTrue();
        tracker.Len().ShouldBe(1);
    }
}
