// 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.
//
// Adapted from server/dirstore.go in the NATS server Go source.

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

namespace ZB.MOM.NatsNet.Server;

/// <summary>
/// Called when a JWT in the store has changed (created or updated) or been deleted.
/// The <c>publicKey</c> argument is the NKey public key for the affected JWT.
/// Mirrors Go <c>JWTChanged</c> delegate type.
/// </summary>
public delegate void JwtChanged(string publicKey);

/// <summary>
/// Controls how deleted JWT files are handled by <see cref="DirJwtStore"/>.
/// Mirrors Go <c>deleteType</c> constants.
/// </summary>
public enum JwtDeleteType
{
    /// <summary>Deletion is not permitted.</summary>
    NoDelete = 0,

    /// <summary>The JWT file is renamed with a <c>.deleted</c> suffix rather than removed.</summary>
    RenameDeleted = 1,

    /// <summary>The JWT file is permanently removed from disk.</summary>
    HardDelete = 2,
}

/// <summary>
/// A directory-backed JWT store that keeps one JWT per public key as a <c>.jwt</c> file.
/// Optionally shards files into two-character subdirectories, tracks expiration via a
/// background timer, and enforces a per-store file-count limit with optional LRU eviction.
/// Mirrors Go <c>DirJWTStore</c>.
/// </summary>
public sealed class DirJwtStore : IDisposable
{
    // ---------------------------------------------------------------------------
    // Constants
    // ---------------------------------------------------------------------------

    private const string FileExtension = ".jwt";
    private const string DeletedSuffix = ".deleted";

    // ---------------------------------------------------------------------------
    // State
    // ---------------------------------------------------------------------------

    private readonly object _lock = new();
    private readonly string _directory;
    private readonly bool _shard;
    private readonly bool _readonly;
    private readonly JwtDeleteType _deleteType;
    private ExpirationTracker? _expiration;
    private readonly JwtChanged? _changed;
    private readonly JwtChanged? _deleted;

    // ---------------------------------------------------------------------------
    // Constructors (private — use factory methods)
    // ---------------------------------------------------------------------------

    private DirJwtStore(
        string directory,
        bool shard,
        bool readOnly,
        JwtDeleteType deleteType,
        JwtChanged? changed,
        JwtChanged? deleted)
    {
        _directory = directory;
        _shard = shard;
        _readonly = readOnly;
        _deleteType = deleteType;
        _changed = changed;
        _deleted = deleted;
    }

    // ---------------------------------------------------------------------------
    // Factory methods
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Creates a read-only directory JWT store.
    /// The directory must already exist; it is never created.
    /// Mirrors Go <c>NewImmutableDirJWTStore</c>.
    /// </summary>
    /// <param name="dirPath">Path to the directory containing JWT files.</param>
    /// <param name="shard">When true, files are stored in two-character sharded subdirectories.</param>
    public static DirJwtStore NewImmutableDirJwtStore(string dirPath, bool shard)
    {
        var store = NewDirJwtStore(dirPath, shard, create: false);
        // Override: force readonly flag (NewDirJwtStore defaults to non-readonly)
        return new DirJwtStore(store._directory, store._shard, readOnly: true,
            JwtDeleteType.NoDelete, changed: null, deleted: null);
    }

    /// <summary>
    /// Creates a directory JWT store without expiration tracking.
    /// Mirrors Go <c>NewDirJWTStore</c>.
    /// </summary>
    /// <param name="dirPath">Path to the directory.</param>
    /// <param name="shard">When true, files are stored in sharded subdirectories.</param>
    /// <param name="create">When true, the directory is created if it does not exist.</param>
    public static DirJwtStore NewDirJwtStore(string dirPath, bool shard, bool create)
    {
        var fullPath = NewDir(dirPath, create);
        return new DirJwtStore(fullPath, shard, readOnly: false,
            JwtDeleteType.NoDelete, changed: null, deleted: null);
    }

    /// <summary>
    /// Creates a directory JWT store with expiration tracking, file-count limiting, and
    /// optional LRU eviction. A background timer fires every <paramref name="expireCheck"/>
    /// and removes JWT files whose expiration time has passed.
    /// Mirrors Go <c>NewExpiringDirJWTStore</c>.
    /// </summary>
    /// <param name="dirPath">Path to the directory.</param>
    /// <param name="shard">When true, files are stored in sharded subdirectories.</param>
    /// <param name="create">When true, the directory is created if it does not exist.</param>
    /// <param name="deleteType">Controls how expired/deleted files are removed.</param>
    /// <param name="expireCheck">
    ///     Interval at which the background timer checks for expired JWTs.
    ///     If zero or negative, defaults to <c>ttl/2</c> or one minute, whichever is smaller.
    /// </param>
    /// <param name="limit">
    ///     Maximum number of JWTs tracked simultaneously.
    ///     Pass <see cref="long.MaxValue"/> to disable the limit.
    /// </param>
    /// <param name="evictOnLimit">
    ///     When true, the least-recently-used JWT is evicted when the limit is reached;
    ///     when false, an error is returned instead.
    /// </param>
    /// <param name="ttl">
    ///     When non-zero, overrides the per-JWT expiration with a fixed TTL from the last
    ///     access. Pass <see cref="TimeSpan.MaxValue"/> to make JWTs indefinitely valid.
    /// </param>
    /// <param name="changeNotification">Callback invoked when a JWT is added or updated.</param>
    public static DirJwtStore NewExpiringDirJwtStore(
        string dirPath,
        bool shard,
        bool create,
        JwtDeleteType deleteType,
        TimeSpan expireCheck,
        long limit,
        bool evictOnLimit,
        TimeSpan ttl,
        JwtChanged? changeNotification)
    {
        var fullPath = NewDir(dirPath, create);

        // Mirror Go: derive a sensible default check interval when caller passes ≤0.
        if (expireCheck <= TimeSpan.Zero)
        {
            if (ttl != TimeSpan.Zero)
            {
                expireCheck = ttl / 2;
            }
            if (expireCheck == TimeSpan.Zero || expireCheck > TimeSpan.FromMinutes(1))
            {
                expireCheck = TimeSpan.FromMinutes(1);
            }
        }

        if (limit <= 0)
        {
            limit = long.MaxValue;
        }

        var store = new DirJwtStore(fullPath, shard, readOnly: false,
            deleteType, changed: changeNotification, deleted: null);

        store.StartExpiring(expireCheck, limit, evictOnLimit, ttl);

        // Pre-index all existing JWT files on disk.
        lock (store._lock)
        {
            foreach (var path in EnumerateJwtFiles(fullPath))
            {
                try
                {
                    var theJwt = File.ReadAllBytes(path);
                    var hash = SHA256.HashData(theJwt);
                    var file = Path.GetFileNameWithoutExtension(path);
                    store._expiration!.Track(file, hash, Encoding.UTF8.GetString(theJwt));
                }
                catch
                {
                    // Ignore files that cannot be read during startup — mirrors Go behaviour.
                }
            }
        }

        return store;
    }

    // ---------------------------------------------------------------------------
    // Public API
    // ---------------------------------------------------------------------------

    /// <summary>Returns true when the store was opened in read-only mode.</summary>
    public bool IsReadOnly() => _readonly;

    /// <summary>
    /// Loads the JWT for the given account public key.
    /// Mirrors Go <c>DirJWTStore.LoadAcc</c>.
    /// </summary>
    public string LoadAcc(string publicKey) => Load(publicKey);

    /// <summary>
    /// Saves the JWT for the given account public key.
    /// Mirrors Go <c>DirJWTStore.SaveAcc</c>.
    /// </summary>
    public void SaveAcc(string publicKey, string theJwt) => Save(publicKey, theJwt);

    /// <summary>
    /// Loads the JWT activation token identified by <paramref name="hash"/>.
    /// Mirrors Go <c>DirJWTStore.LoadAct</c>.
    /// </summary>
    public string LoadAct(string hash) => Load(hash);

    /// <summary>
    /// Saves the JWT activation token identified by <paramref name="hash"/>.
    /// Mirrors Go <c>DirJWTStore.SaveAct</c>.
    /// </summary>
    public void SaveAct(string hash, string theJwt) => Save(hash, theJwt);

    /// <summary>
    /// Stops the background expiration timer and releases resources.
    /// Mirrors Go <c>DirJWTStore.Close</c>.
    /// </summary>
    public void Close()
    {
        lock (_lock)
        {
            _expiration?.Close();
            _expiration = null;
        }
    }

    /// <inheritdoc/>
    public void Dispose() => Close();

    /// <summary>
    /// Packs up to <paramref name="maxJwts"/> JWTs from the store into a newline-delimited
    /// string where each line is <c>publicKey|jwtData</c>.
    /// Pass a negative value for <paramref name="maxJwts"/> to include all JWTs.
    /// Mirrors Go <c>DirJWTStore.Pack</c>.
    /// </summary>
    public string Pack(int maxJwts)
    {
        var pack = new List<string>(maxJwts > 0 ? maxJwts : 16);
        lock (_lock)
        {
            foreach (var path in EnumerateJwtFiles(_directory))
            {
                if (maxJwts >= 0 && pack.Count == maxJwts)
                {
                    break;
                }

                var pubKey = Path.GetFileNameWithoutExtension(path);

                // When expiration is active, only include indexed (tracked) entries.
                if (_expiration != null && !_expiration.IsTracked(pubKey))
                {
                    continue;
                }

                byte[] jwtBytes;
                try
                {
                    jwtBytes = File.ReadAllBytes(path);
                }
                catch
                {
                    continue;
                }

                if (jwtBytes.Length == 0)
                {
                    continue;
                }

                // When expiration is active, skip already-expired JWTs.
                if (_expiration != null && IsJwtExpired(jwtBytes))
                {
                    continue;
                }

                pack.Add($"{pubKey}|{Encoding.UTF8.GetString(jwtBytes)}");
            }
        }
        return string.Join("\n", pack);
    }

    /// <summary>
    /// Walks all JWT files in batches of <paramref name="maxJwts"/>, invoking
    /// <paramref name="callback"/> with each partial pack message.
    /// Mirrors Go <c>DirJWTStore.PackWalk</c>.
    /// </summary>
    /// <exception cref="ArgumentException">
    ///     Thrown when <paramref name="maxJwts"/> is zero or negative, or
    ///     <paramref name="callback"/> is null.
    /// </exception>
    public void PackWalk(int maxJwts, Action<string> callback)
    {
        if (maxJwts <= 0 || callback == null)
        {
            throw new ArgumentException("bad arguments to PackWalk");
        }

        string directory;
        ExpirationTracker? exp;
        lock (_lock)
        {
            directory = _directory;
            exp = _expiration;
        }

        var batch = new List<string>(maxJwts);

        foreach (var path in EnumerateJwtFiles(directory))
        {
            var pubKey = Path.GetFileNameWithoutExtension(path);

            lock (_lock)
            {
                if (exp != null && !exp.IsTracked(pubKey))
                {
                    continue;
                }
            }

            byte[] jwtBytes;
            try
            {
                jwtBytes = File.ReadAllBytes(path);
            }
            catch
            {
                continue;
            }

            if (jwtBytes.Length == 0)
            {
                continue;
            }

            if (exp != null && IsJwtExpired(jwtBytes))
            {
                continue;
            }

            batch.Add($"{pubKey}|{Encoding.UTF8.GetString(jwtBytes)}");

            if (batch.Count == maxJwts)
            {
                callback(string.Join("\n", batch));
                batch.Clear();
            }
        }

        if (batch.Count > 0)
        {
            callback(string.Join("\n", batch));
        }
    }

    /// <summary>
    /// Merges the JWTs from a pack string (as produced by <see cref="Pack"/>) into the store.
    /// Each line must be of the form <c>publicKey|jwtData</c>.
    /// Mirrors Go <c>DirJWTStore.Merge</c>.
    /// </summary>
    /// <exception cref="InvalidOperationException">
    ///     Thrown when any line is malformed or the public key is not a valid account key.
    /// </exception>
    public void Merge(string pack)
    {
        var lines = pack.Split('\n');
        foreach (var line in lines)
        {
            if (string.IsNullOrEmpty(line))
            {
                continue;
            }

            var pipeIndex = line.IndexOf('|');
            if (pipeIndex < 0)
            {
                throw new InvalidOperationException(
                    $"Line in package didn't contain 2 entries: \"{line}\"");
            }

            var pubKey = line[..pipeIndex];
            var jwtData = line[(pipeIndex + 1)..];

            // Validate that this is a plausible public account key (non-empty, reasonable length).
            if (string.IsNullOrWhiteSpace(pubKey))
            {
                throw new InvalidOperationException(
                    "Key to merge is not a valid public account key");
            }

            SaveIfNewer(pubKey, jwtData);
        }
    }

    /// <summary>
    /// Reloads all JWT files from disk into the expiration tracker, invoking the
    /// change callback for any JWT whose content differs from the in-memory record.
    /// No-ops when expiration tracking is disabled or the store is read-only.
    /// Mirrors Go <c>DirJWTStore.Reload</c>.
    /// </summary>
    public void Reload()
    {
        ExpirationTracker? exp;
        Dictionary<string, byte[]>? oldHashes;
        JwtChanged? changed;
        bool isCache;

        lock (_lock)
        {
            exp = _expiration;
            if (exp == null || _readonly)
            {
                return;
            }

            changed = _changed;
            isCache = exp.EvictOnLimit;
            oldHashes = exp.SnapshotHashes();

            // Clear existing tracking state — mirrors Go clearing heap/idx/lru/hash.
            exp.Reset();
        }

        foreach (var path in EnumerateJwtFiles(_directory))
        {
            try
            {
                var theJwt = File.ReadAllBytes(path);
                var hash = SHA256.HashData(theJwt);
                var file = Path.GetFileNameWithoutExtension(path);

                // Determine whether the callback should fire.
                var notify = isCache; // for caches, always notify (entry may have been evicted)
                if (oldHashes != null && oldHashes.TryGetValue(file, out var oldHash))
                {
                    notify = !hash.AsSpan().SequenceEqual(oldHash);
                }

                lock (_lock)
                {
                    exp.Track(file, hash, Encoding.UTF8.GetString(theJwt));
                }

                if (notify)
                {
                    changed?.Invoke(file);
                }
            }
            catch
            {
                // Ignore unreadable files — mirrors Go behaviour.
            }
        }
    }

    /// <summary>
    /// Returns the XOR of all individual JWT SHA-256 hashes currently tracked by the
    /// expiration index. Returns a zero-filled array when expiration tracking is disabled.
    /// Mirrors Go <c>DirJWTStore.Hash</c>.
    /// </summary>
    public byte[] Hash()
    {
        lock (_lock)
        {
            return _expiration?.GetHash() ?? new byte[SHA256.HashSizeInBytes];
        }
    }

    // ---------------------------------------------------------------------------
    // Private helpers — path
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Returns the filesystem path for the given public key, optionally under a two-character
    /// shard subdirectory derived from the last two characters of the key.
    /// Mirrors Go <c>DirJWTStore.pathForKey</c>.
    /// </summary>
    private string PathForKey(string publicKey)
    {
        if (publicKey.Length < 2)
        {
            return string.Empty;
        }

        var fileName = publicKey + FileExtension;

        if (_shard)
        {
            // Go uses the last two characters of the key as the shard directory name.
            var shard = publicKey[^2..];
            return Path.Combine(_directory, shard, fileName);
        }

        return Path.Combine(_directory, fileName);
    }

    // ---------------------------------------------------------------------------
    // Private helpers — load / write / save
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Loads the JWT for <paramref name="publicKey"/> from disk.
    /// Mirrors Go <c>DirJWTStore.load</c>.
    /// </summary>
    private string Load(string publicKey)
    {
        lock (_lock)
        {
            var path = PathForKey(publicKey);
            if (string.IsNullOrEmpty(path))
            {
                throw new InvalidOperationException("Invalid public key");
            }

            string data;
            try
            {
                data = File.ReadAllText(path);
            }
            catch (FileNotFoundException)
            {
                throw new FileNotFoundException($"JWT not found for key: {publicKey}", path);
            }

            _expiration?.UpdateTrack(publicKey);
            return data;
        }
    }

    /// <summary>
    /// Writes <paramref name="theJwt"/> to <paramref name="path"/> and updates the expiration
    /// tracker. Returns true when a new file was created or the content changed; false when the
    /// content is identical to what is already tracked.
    /// Assumes the lock is already held.
    /// Mirrors Go <c>DirJWTStore.write</c>.
    /// </summary>
    private bool Write(string path, string publicKey, string theJwt)
    {
        if (string.IsNullOrEmpty(theJwt))
        {
            throw new InvalidOperationException("Invalid JWT");
        }

        byte[]? newHash = null;

        if (_expiration != null)
        {
            newHash = SHA256.HashData(Encoding.UTF8.GetBytes(theJwt));

            if (_expiration.IsTracked(publicKey))
            {
                _expiration.UpdateTrack(publicKey);

                // If content is identical, skip the write — mirrors Go.
                var existingHash = _expiration.GetItemHash(publicKey);
                if (existingHash != null && newHash.AsSpan().SequenceEqual(existingHash))
                {
                    return false;
                }
            }
            else if (_expiration.Count >= _expiration.Limit)
            {
                if (!_expiration.EvictOnLimit)
                {
                    throw new InvalidOperationException("JWT store is full");
                }

                // Evict the least-recently-used entry.
                var lruKey = _expiration.LruFront();
                if (lruKey != null)
                {
                    var lruPath = PathForKey(lruKey);
                    try
                    {
                        File.Delete(lruPath);
                    }
                    catch
                    {
                        // Best-effort removal.
                    }
                    _expiration.UnTrack(lruKey);
                }
            }
        }

        File.WriteAllText(path, theJwt);

        if (_expiration != null && newHash != null)
        {
            _expiration.Track(publicKey, newHash, theJwt);
        }

        return true;
    }

    /// <summary>
    /// Deletes the JWT for <paramref name="publicKey"/> according to <see cref="_deleteType"/>.
    /// Mirrors Go <c>DirJWTStore.delete</c>.
    /// </summary>
    public void Delete(string publicKey)
    {
        if (_readonly)
        {
            throw new InvalidOperationException("Store is read-only");
        }
        if (_deleteType == JwtDeleteType.NoDelete)
        {
            throw new InvalidOperationException("Store is not set up for delete");
        }

        lock (_lock)
        {
            var name = PathForKey(publicKey);

            if (_deleteType == JwtDeleteType.RenameDeleted)
            {
                try
                {
                    File.Move(name, name + DeletedSuffix, overwrite: true);
                }
                catch (FileNotFoundException)
                {
                    return; // already gone — mirrors Go os.IsNotExist behaviour
                }
            }
            else
            {
                try
                {
                    File.Delete(name);
                }
                catch (FileNotFoundException)
                {
                    return;
                }
            }

            _expiration?.UnTrack(publicKey);
            _deleted?.Invoke(publicKey);
        }
    }

    /// <summary>
    /// Saves <paramref name="theJwt"/> for <paramref name="publicKey"/>, creating shard
    /// directories as needed, and fires the change callback if the content changed.
    /// Mirrors Go <c>DirJWTStore.save</c>.
    /// </summary>
    private void Save(string publicKey, string theJwt)
    {
        if (_readonly)
        {
            throw new InvalidOperationException("Store is read-only");
        }

        bool changed;
        JwtChanged? cb;

        lock (_lock)
        {
            var path = PathForKey(publicKey);
            if (string.IsNullOrEmpty(path))
            {
                throw new InvalidOperationException("Invalid public key");
            }

            // Ensure the shard subdirectory exists.
            var dirPath = Path.GetDirectoryName(path)!;
            if (!Directory.Exists(dirPath))
            {
                Directory.CreateDirectory(dirPath);
            }

            changed = Write(path, publicKey, theJwt);
            cb = _changed;
        }

        if (changed)
        {
            cb?.Invoke(publicKey);
        }
    }

    /// <summary>
    /// Saves <paramref name="theJwt"/> only if it is newer than what is already on disk
    /// (determined by comparing the <c>iat</c> field decoded from the JWT payload).
    /// Mirrors Go <c>DirJWTStore.saveIfNewer</c>.
    /// </summary>
    private void SaveIfNewer(string publicKey, string theJwt)
    {
        if (_readonly)
        {
            throw new InvalidOperationException("Store is read-only");
        }

        var path = PathForKey(publicKey);
        if (string.IsNullOrEmpty(path))
        {
            throw new InvalidOperationException("Invalid public key");
        }

        // Ensure the shard subdirectory exists.
        var dirPath = Path.GetDirectoryName(path)!;
        if (!Directory.Exists(dirPath))
        {
            Directory.CreateDirectory(dirPath);
        }

        if (File.Exists(path))
        {
            // Decode both JWTs to compare issued-at timestamps.
            // JWT is three base64url parts separated by '.': header.payload.signature
            long newIssuedAt = DecodeJwtIssuedAt(theJwt);
            long existingIssuedAt = 0;
            string? newId = DecodeJwtId(theJwt);
            string? existingId = null;

            try
            {
                var existingData = File.ReadAllText(path);
                existingIssuedAt = DecodeJwtIssuedAt(existingData);
                existingId = DecodeJwtId(existingData);

                // Skip if same ID (already up to date).
                if (newId != null && existingId != null && newId == existingId)
                {
                    return;
                }

                // Skip if existing is newer.
                if (existingIssuedAt > newIssuedAt)
                {
                    return;
                }
            }
            catch
            {
                // Cannot decode existing JWT — proceed with overwrite (mirrors Go: "skip if it
                // can't be decoded" means the error path falls through to the write).
            }
        }

        bool changed;
        JwtChanged? cb;

        lock (_lock)
        {
            cb = _changed;
            changed = Write(path, publicKey, theJwt);
        }

        if (changed)
        {
            cb?.Invoke(publicKey);
        }
    }

    // ---------------------------------------------------------------------------
    // Private helpers — expiration management
    // ---------------------------------------------------------------------------

    private void StartExpiring(TimeSpan reCheck, long limit, bool evictOnLimit, TimeSpan ttl)
    {
        lock (_lock)
        {
            var tracker = new ExpirationTracker(limit, evictOnLimit, ttl);
            _expiration = tracker;

            // Background timer — mirrors Go goroutine + time.Ticker.
            var timer = new Timer(_ =>
            {
                var now = DateTimeOffset.UtcNow.UtcTicks;

                while (true)
                {
                    string? expiredKey;
                    lock (_lock)
                    {
                        expiredKey = tracker.PeekExpired(now);
                        if (expiredKey == null)
                        {
                            break;
                        }

                        var expiredPath = PathForKey(expiredKey);
                        try
                        {
                            File.Delete(expiredPath);
                        }
                        catch
                        {
                            // If delete fails, stop trying — mirrors Go: "if err := os.Remove ...; err == nil".
                            break;
                        }

                        tracker.PopExpired();
                        tracker.UnTrack(expiredKey);
                    }
                }
            }, null, reCheck, reCheck);

            tracker.SetTimer(timer);
        }
    }

    // ---------------------------------------------------------------------------
    // Private static helpers
    // ---------------------------------------------------------------------------

    /// <summary>
    /// Validates the supplied path exists, and enforces whether it must be a
    /// directory or regular file.
    /// Mirrors Go <c>validatePathExists</c>.
    /// </summary>
    internal static string ValidatePathExists(string path, bool dir)
    {
        if (string.IsNullOrWhiteSpace(path))
        {
            throw new ArgumentException("path is not specified", nameof(path));
        }

        string absolutePath;
        try
        {
            absolutePath = Path.GetFullPath(path);
        }
        catch (Exception ex)
        {
            throw new InvalidOperationException($"error parsing path [{path}]: {ex.Message}", ex);
        }

        if (!File.Exists(absolutePath) && !Directory.Exists(absolutePath))
        {
            throw new InvalidOperationException($"the path [{absolutePath}] doesn't exist");
        }

        var attributes = File.GetAttributes(absolutePath);
        var isDirectory = (attributes & FileAttributes.Directory) == FileAttributes.Directory;

        if (dir && !isDirectory)
        {
            throw new InvalidOperationException($"the path [{absolutePath}] is not a directory");
        }

        if (!dir && isDirectory)
        {
            throw new InvalidOperationException($"the path [{absolutePath}] is not a file");
        }

        return absolutePath;
    }

    /// <summary>
    /// Validates the supplied path exists and is a directory.
    /// Mirrors Go <c>validateDirPath</c>.
    /// </summary>
    internal static string ValidateDirPath(string path)
    {
        return ValidatePathExists(path, dir: true);
    }

    /// <summary>
    /// Validates that <paramref name="dirPath"/> exists and is a directory, optionally
    /// creating it when <paramref name="create"/> is true.
    /// Returns the absolute path.
    /// Mirrors Go <c>newDir</c>.
    /// </summary>
    private static string NewDir(string dirPath, bool create)
    {
        if (Directory.Exists(dirPath) || File.Exists(dirPath))
        {
            return ValidateDirPath(dirPath);
        }

        if (!create)
        {
            return ValidateDirPath(dirPath);
        }

        Directory.CreateDirectory(dirPath);
        return ValidateDirPath(dirPath);
    }

    /// <summary>
    /// Enumerates all <c>.jwt</c> files beneath <paramref name="rootDirectory"/> recursively,
    /// skipping files whose names end with <c>.deleted</c>.
    /// </summary>
    private static IEnumerable<string> EnumerateJwtFiles(string rootDirectory)
    {
        return Directory
            .EnumerateFiles(rootDirectory, "*" + FileExtension, SearchOption.AllDirectories)
            .Where(p => !p.EndsWith(DeletedSuffix, StringComparison.Ordinal));
    }

    /// <summary>
    /// Returns true when the JWT encoded in <paramref name="jwtBytes"/> has a non-zero
    /// expiration claim that is in the past.
    /// </summary>
    private static bool IsJwtExpired(byte[] jwtBytes)
    {
        try
        {
            var jwtString = Encoding.UTF8.GetString(jwtBytes);
            var exp = DecodeJwtExp(jwtString);
            if (exp > 0)
            {
                return exp < DateTimeOffset.UtcNow.ToUnixTimeSeconds();
            }
        }
        catch
        {
            // Malformed JWT — treat as not expired.
        }
        return false;
    }

    /// <summary>
    /// Decodes the <c>exp</c> (expiration) claim from the JWT payload.
    /// Returns 0 when not present or on decode error.
    /// JWTs are three base64url segments separated by '.': header.payload.signature.
    /// </summary>
    private static long DecodeJwtExp(string jwt)
    {
        return DecodeLongClaim(jwt, "\"exp\":");
    }

    /// <summary>
    /// Decodes the <c>iat</c> (issued-at) claim from the JWT payload.
    /// Returns 0 when not present or on decode error.
    /// </summary>
    private static long DecodeJwtIssuedAt(string jwt)
    {
        return DecodeLongClaim(jwt, "\"iat\":");
    }

    /// <summary>
    /// Decodes the <c>jti</c> (JWT ID) claim from the JWT payload.
    /// Returns null when not present or on decode error.
    /// </summary>
    private static string? DecodeJwtId(string jwt)
    {
        return DecodeStringClaim(jwt, "\"jti\":\"");
    }

    private static long DecodeLongClaim(string jwt, string claimKey)
    {
        try
        {
            var parts = jwt.Split('.');
            if (parts.Length < 2)
            {
                return 0;
            }

            var payload = Base64UrlDecode(parts[1]);
            var json = Encoding.UTF8.GetString(payload);

            var idx = json.IndexOf(claimKey, StringComparison.Ordinal);
            if (idx < 0)
            {
                return 0;
            }

            var valueStart = idx + claimKey.Length;
            var valueEnd = valueStart;
            while (valueEnd < json.Length &&
                   (char.IsDigit(json[valueEnd]) || json[valueEnd] == '-'))
            {
                valueEnd++;
            }

            if (long.TryParse(json.AsSpan(valueStart, valueEnd - valueStart), out var result))
            {
                return result;
            }
        }
        catch
        {
            // Ignore decode errors.
        }
        return 0;
    }

    private static string? DecodeStringClaim(string jwt, string claimKeyPrefix)
    {
        try
        {
            var parts = jwt.Split('.');
            if (parts.Length < 2)
            {
                return null;
            }

            var payload = Base64UrlDecode(parts[1]);
            var json = Encoding.UTF8.GetString(payload);

            var idx = json.IndexOf(claimKeyPrefix, StringComparison.Ordinal);
            if (idx < 0)
            {
                return null;
            }

            var valueStart = idx + claimKeyPrefix.Length;
            var valueEnd = json.IndexOf('"', valueStart);
            if (valueEnd < 0)
            {
                return null;
            }

            return json[valueStart..valueEnd];
        }
        catch
        {
            return null;
        }
    }

    /// <summary>Decodes a base64url-encoded string (no padding required).</summary>
    private static byte[] Base64UrlDecode(string input)
    {
        var padded = input
            .Replace('-', '+')
            .Replace('_', '/');

        switch (padded.Length % 4)
        {
            case 2: padded += "=="; break;
            case 3: padded += "="; break;
        }

        return Convert.FromBase64String(padded);
    }

    /// <summary>
    /// XOR-assigns each byte of <paramref name="rVal"/> into <paramref name="lVal"/>.
    /// Mirrors Go <c>xorAssign</c>.
    /// </summary>
    private static void XorAssign(byte[] lVal, byte[] rVal)
    {
        for (var i = 0; i < rVal.Length && i < lVal.Length; i++)
        {
            lVal[i] ^= rVal[i];
        }
    }
}

// ---------------------------------------------------------------------------
// Expiration tracker
// ---------------------------------------------------------------------------

/// <summary>
/// Tracks JWT expiration using a min-heap ordered by expiration timestamp and an LRU
/// doubly-linked list for eviction. Maintains a rolling XOR hash of all tracked JWTs.
/// Mirrors Go <c>expirationTracker</c>.
/// </summary>
internal sealed class ExpirationTracker
{
    // Min-heap ordered by expiration (Unix nanoseconds stored as ticks for TimeSpan compatibility).
    private readonly PriorityQueue<JwtItem, long> _heap;
    private readonly List<JwtItem> _compatHeap;

    // Index from publicKey to JwtItem for O(1) lookup and hash tracking.
    private readonly Dictionary<string, JwtItem> _idx;

    // LRU list — front is least-recently used, back is most-recently used.
    private readonly LinkedList<string> _lru;

    // XOR of all tracked item hashes — matches Go's rolling hash approach.
    private byte[] _hash;

    private Timer? _timer;

    internal long Limit { get; }
    internal bool EvictOnLimit { get; }
    internal TimeSpan Ttl { get; }

    internal int Count => _idx.Count;

    internal ExpirationTracker(long limit, bool evictOnLimit, TimeSpan ttl)
    {
        Limit = limit;
        EvictOnLimit = evictOnLimit;
        Ttl = ttl;
        _heap = new PriorityQueue<JwtItem, long>();
        _compatHeap = [];
        _idx = new Dictionary<string, JwtItem>(StringComparer.Ordinal);
        _lru = new LinkedList<string>();
        _hash = new byte[SHA256.HashSizeInBytes];
    }

    internal void SetTimer(Timer timer) => _timer = timer;

    /// <summary>Returns the number of items in the compatibility heap.</summary>
    /// <remarks>Mirrors Go <c>expirationTracker.Len</c>.</remarks>
    internal int Len() => _compatHeap.Count;

    /// <summary>Returns true when item <paramref name="i"/> expires before <paramref name="j"/>.</summary>
    /// <remarks>Mirrors Go <c>expirationTracker.Less</c>.</remarks>
    internal bool Less(int i, int j)
    {
        return _compatHeap[i].Expiration < _compatHeap[j].Expiration;
    }

    /// <summary>Swaps two compatibility heap items and updates their indexes.</summary>
    /// <remarks>Mirrors Go <c>expirationTracker.Swap</c>.</remarks>
    internal void Swap(int i, int j)
    {
        (_compatHeap[i], _compatHeap[j]) = (_compatHeap[j], _compatHeap[i]);
        _compatHeap[i].Index = i;
        _compatHeap[j].Index = j;
    }

    /// <summary>Adds an item to the compatibility heap and index maps.</summary>
    /// <remarks>Mirrors Go <c>expirationTracker.Push</c>.</remarks>
    internal void Push(JwtItem item)
    {
        item.Index = _compatHeap.Count;
        _compatHeap.Add(item);
        _idx[item.PublicKey] = item;
        _lru.AddLast(item.PublicKey);
    }

    /// <summary>Removes and returns the last compatibility heap item.</summary>
    /// <remarks>Mirrors Go <c>expirationTracker.Pop</c>.</remarks>
    internal JwtItem Pop()
    {
        var n = _compatHeap.Count;
        var item = _compatHeap[n - 1];
        _compatHeap.RemoveAt(n - 1);
        item.Index = -1;

        var node = _lru.Find(item.PublicKey);
        if (node != null)
        {
            _lru.Remove(node);
        }

        _idx.Remove(item.PublicKey);
        return item;
    }

    /// <summary>
    /// Adds or updates tracking for <paramref name="publicKey"/>.
    /// When an entry already exists its expiration and hash are updated.
    /// Mirrors Go <c>expirationTracker.track</c>.
    /// </summary>
    internal void Track(string publicKey, byte[] hash, string theJwt)
    {
        long exp;
        if (Ttl != TimeSpan.Zero)
        {
            exp = Ttl == TimeSpan.MaxValue
                ? long.MaxValue
                : (DateTimeOffset.UtcNow + Ttl).UtcTicks;
        }
        else
        {
            // Decode the JWT expiration field.
            exp = DecodeJwtExpTicks(theJwt);
            if (exp == 0)
            {
                exp = long.MaxValue; // indefinite
            }
        }

        if (_idx.TryGetValue(publicKey, out var existing))
        {
            // Remove old hash contribution from rolling XOR.
            XorAssign(_hash, existing.Hash);

            // Create a new JwtItem so the old heap entry becomes a stale orphan.
            // DrainStale uses ReferenceEquals(current, top) to detect orphans:
            // the old heap entry points to the old JwtItem object which is no longer
            // in _idx, so it will be drained on the next PeekExpired call.
            var updated = new JwtItem(publicKey, exp, hash);
            _idx[publicKey] = updated;
            _heap.Enqueue(updated, exp);
        }
        else
        {
            var item = new JwtItem(publicKey, exp, hash);
            _idx[publicKey] = item;
            _heap.Enqueue(item, exp);
            _lru.AddLast(publicKey);
        }

        XorAssign(_hash, hash);
    }

    /// <summary>
    /// Updates the LRU position and optionally the expiration for <paramref name="publicKey"/>.
    /// Mirrors Go <c>expirationTracker.updateTrack</c>.
    /// </summary>
    internal void UpdateTrack(string publicKey)
    {
        if (!_idx.TryGetValue(publicKey, out var item))
        {
            return;
        }

        if (Ttl != TimeSpan.Zero)
        {
            var newExp = Ttl == TimeSpan.MaxValue
                ? long.MaxValue
                : (DateTimeOffset.UtcNow + Ttl).UtcTicks;

            // Replace with a new JwtItem so the old heap entry becomes a stale orphan
            // (DrainStale detects staleness via ReferenceEquals).
            var updated = new JwtItem(publicKey, newExp, item.Hash);
            _idx[publicKey] = updated;
            _heap.Enqueue(updated, newExp);
        }

        if (EvictOnLimit)
        {
            // Move to back of LRU list (most recently used).
            var node = _lru.Find(publicKey);
            if (node != null)
            {
                _lru.Remove(node);
                _lru.AddLast(publicKey);
            }
        }
    }

    /// <summary>
    /// Removes tracking for <paramref name="publicKey"/> and updates the rolling hash.
    /// Mirrors Go <c>expirationTracker.unTrack</c>.
    /// </summary>
    internal void UnTrack(string publicKey)
    {
        if (!_idx.TryGetValue(publicKey, out var item))
        {
            return;
        }

        XorAssign(_hash, item.Hash);
        _idx.Remove(publicKey);

        var node = _lru.Find(publicKey);
        if (node != null)
        {
            _lru.Remove(node);
        }

        // Invalidate heap entry so it is ignored when it surfaces during PeekExpired/PopExpired.
        item.Removed = true;
    }

    /// <summary>Returns true when <paramref name="publicKey"/> is currently tracked.</summary>
    internal bool IsTracked(string publicKey) => _idx.ContainsKey(publicKey);

    /// <summary>Returns the hash for <paramref name="publicKey"/>, or null when not tracked.</summary>
    internal byte[]? GetItemHash(string publicKey)
        => _idx.TryGetValue(publicKey, out var item) ? item.Hash : null;

    /// <summary>Returns the public key at the front of the LRU list (least recently used).</summary>
    internal string? LruFront() => _lru.First?.Value;

    /// <summary>
    /// Peeks at the min-heap root and returns the public key if its expiration is at or
    /// before <paramref name="nowTicks"/>. Returns null when no entries are expired.
    /// </summary>
    internal string? PeekExpired(long nowTicks)
    {
        // Drain stale (removed or version-superseded) heap entries.
        DrainStale();

        if (_heap.Count == 0)
        {
            return null;
        }

        _heap.TryPeek(out var item, out var priority);
        if (item == null || priority > nowTicks)
        {
            return null;
        }

        return item.Removed || !_idx.ContainsKey(item.PublicKey) ? null : item.PublicKey;
    }

    /// <summary>
    /// Removes the min-heap root (the entry returned by the most recent <see cref="PeekExpired"/>).
    /// </summary>
    internal void PopExpired()
    {
        DrainStale();
        if (_heap.Count > 0)
        {
            _heap.Dequeue();
        }
    }

    /// <summary>Returns a copy of the rolling XOR hash.</summary>
    internal byte[] GetHash()
    {
        var copy = new byte[_hash.Length];
        _hash.CopyTo(copy, 0);
        return copy;
    }

    /// <summary>
    /// Snapshots the current item hashes keyed by public key, for use by
    /// <see cref="DirJwtStore.Reload"/>.
    /// </summary>
    internal Dictionary<string, byte[]> SnapshotHashes()
    {
        var snap = new Dictionary<string, byte[]>(_idx.Count, StringComparer.Ordinal);
        foreach (var (key, item) in _idx)
        {
            var copy = new byte[item.Hash.Length];
            item.Hash.CopyTo(copy, 0);
            snap[key] = copy;
        }
        return snap;
    }

    /// <summary>
    /// Resets all tracking state in preparation for a full reload.
    /// Mirrors Go clearing of <c>heap</c>, <c>idx</c>, <c>lru</c>, and <c>hash</c>.
    /// </summary>
    internal void Reset()
    {
        _heap.Clear();
        _compatHeap.Clear();
        _idx.Clear();
        _lru.Clear();
        Array.Clear(_hash);
    }

    /// <summary>Stops the background expiration timer.</summary>
    internal void Close()
    {
        _timer?.Dispose();
        _timer = null;
    }

    // ---------------------------------------------------------------------------
    // Private helpers
    // ---------------------------------------------------------------------------

    private void DrainStale()
    {
        while (_heap.Count > 0 &&
               _heap.TryPeek(out var top, out _) &&
               (top.Removed || !_idx.TryGetValue(top.PublicKey, out var current) || !ReferenceEquals(current, top)))
        {
            _heap.Dequeue();
        }
    }

    private static void XorAssign(byte[] lVal, byte[] rVal)
    {
        for (var i = 0; i < rVal.Length && i < lVal.Length; i++)
        {
            lVal[i] ^= rVal[i];
        }
    }

    private static long DecodeJwtExpTicks(string jwt)
    {
        try
        {
            var parts = jwt.Split('.');
            if (parts.Length < 2)
            {
                return 0;
            }

            var padded = parts[1].Replace('-', '+').Replace('_', '/');
            switch (padded.Length % 4)
            {
                case 2: padded += "=="; break;
                case 3: padded += "="; break;
            }

            var payload = Convert.FromBase64String(padded);
            var json = Encoding.UTF8.GetString(payload);

            const string expKey = "\"exp\":";
            var idx = json.IndexOf(expKey, StringComparison.Ordinal);
            if (idx < 0)
            {
                return 0;
            }

            var valueStart = idx + expKey.Length;
            var valueEnd = valueStart;
            while (valueEnd < json.Length &&
                   (char.IsDigit(json[valueEnd]) || json[valueEnd] == '-'))
            {
                valueEnd++;
            }

            if (long.TryParse(json.AsSpan(valueStart, valueEnd - valueStart), out var expSeconds))
            {
                // Convert Unix seconds to UTC ticks for comparison with DateTimeOffset.UtcTicks.
                return DateTimeOffset.FromUnixTimeSeconds(expSeconds).UtcTicks;
            }
        }
        catch
        {
            // Ignore.
        }
        return 0;
    }
}

// ---------------------------------------------------------------------------
// Heap item
// ---------------------------------------------------------------------------

/// <summary>
/// Represents a single JWT entry in the expiration tracker's min-heap.
/// Mirrors Go <c>jwtItem</c>.
/// </summary>
internal sealed class JwtItem
{
    internal int Index { get; set; }
    internal string PublicKey { get; }
    internal long Expiration { get; set; }
    internal byte[] Hash { get; set; }

    /// <summary>
    /// Monotonically increasing counter; when a heap entry's version differs from the
    /// canonical item in the index it is considered stale and is skipped.
    /// </summary>
    internal int Version { get; set; }

    /// <summary>Marked true by <see cref="ExpirationTracker.UnTrack"/> to invalidate heap entries.</summary>
    internal bool Removed { get; set; }

    internal JwtItem(string publicKey, long expiration, byte[] hash)
    {
        Index = -1;
        PublicKey = publicKey;
        Expiration = expiration;
        Hash = hash;
    }
}
