using System.Collections.Concurrent;

namespace ZB.MOM.WW.ScadaBridge.Transport.Import;

/// <summary>
/// In-memory sliding-window rate limiter for bundle-unlock passphrase
/// attempts, keyed by client IP. The design doc (§11) declares a per-IP-per-hour cap
/// (default 10) as a brute-force defence against a stolen bundle; this class is the
/// minimal server-side implementation.
/// <para>
/// Algorithm: each key (an IP string, or any opaque caller identifier) holds a queue
/// of attempt timestamps. <see cref="TryRegisterAttempt"/> first prunes entries older
/// than the configured window, then either appends the current timestamp and returns
/// <c>true</c> if the count is still under the threshold, or refuses to append and
/// returns <c>false</c> if appending would cross it. The trailing-hour count is the
/// queue length post-prune.
/// </para>
/// <para>
/// Storage is a process-local <see cref="ConcurrentDictionary{TKey,TValue}"/>. The
/// counters do not survive a host restart — that is by design: a restart resets the
/// brute-force window in favour of legitimate operators after an outage. Persisting
/// the counters would require a multi-node consensus story the simple in-memory
/// design avoids.
/// </para>
/// <para>
/// Thread-safety: the per-key queue is protected by a per-key lock taken inside the
/// dictionary value; the dictionary itself is concurrent. The class is safe to call
/// from multiple threads / circuits without external coordination.
/// </para>
/// </summary>
public sealed class BundleUnlockRateLimiter
{
    /// <summary>
    /// Default trailing window. The design doc's "per-IP-per-hour" wording fixes this
    /// at 60 minutes; a constructor overload accepts a different window for tests.
    /// </summary>
    public static readonly TimeSpan DefaultWindow = TimeSpan.FromHours(1);

    private readonly ConcurrentDictionary<string, AttemptBucket> _buckets = new(StringComparer.OrdinalIgnoreCase);
    private readonly TimeProvider _timeProvider;
    private readonly TimeSpan _window;

    /// <summary>
    /// Initializes a new <see cref="BundleUnlockRateLimiter"/> using the documented
    /// 1-hour trailing window and the system clock. Suitable for production DI.
    /// </summary>
    public BundleUnlockRateLimiter() : this(TimeProvider.System, DefaultWindow)
    {
    }

    /// <summary>
    /// Initializes a new <see cref="BundleUnlockRateLimiter"/> with an injected clock
    /// (for deterministic tests) and a custom trailing window.
    /// </summary>
    /// <param name="timeProvider">Clock used for both timestamping new attempts and pruning expired ones.</param>
    /// <param name="window">Trailing window over which attempts are counted (typically 1 hour).</param>
    public BundleUnlockRateLimiter(TimeProvider timeProvider, TimeSpan window)
    {
        ArgumentNullException.ThrowIfNull(timeProvider);
        if (window <= TimeSpan.Zero)
        {
            throw new ArgumentOutOfRangeException(nameof(window), "Window must be positive.");
        }

        _timeProvider = timeProvider;
        _window = window;
    }

    /// <summary>
    /// Attempts to register a new passphrase try against the configured per-key
    /// limit. Returns <c>true</c> when the attempt is permitted (and recorded);
    /// returns <c>false</c> when the key has exhausted its budget for the trailing
    /// window — the caller should reject the unlock request with a 429-equivalent.
    /// </summary>
    /// <param name="clientKey">
    /// Opaque caller identifier — typically the remote IP, but any stable per-source
    /// string is acceptable (the limiter does not interpret it). Trimmed for matching.
    /// </param>
    /// <param name="maxAttemptsPerWindow">
    /// The trailing-window cap (e.g. <c>TransportOptions.MaxUnlockAttemptsPerIpPerHour</c>,
    /// default 10). Must be at least 1.
    /// </param>
    /// <returns>
    /// <c>true</c> if the attempt was registered (within budget); <c>false</c> if the
    /// caller has already used <paramref name="maxAttemptsPerWindow"/> within the
    /// trailing window.
    /// </returns>
    public bool TryRegisterAttempt(string clientKey, int maxAttemptsPerWindow)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
        if (maxAttemptsPerWindow < 1)
        {
            throw new ArgumentOutOfRangeException(
                nameof(maxAttemptsPerWindow), "Limit must be at least 1.");
        }

        var bucket = _buckets.GetOrAdd(clientKey.Trim(), _ => new AttemptBucket());
        var now = _timeProvider.GetUtcNow();
        var cutoff = now - _window;

        lock (bucket)
        {
            // Prune expired entries first so a caller that paused longer than the
            // window starts the next round at zero — not penalised by stale rows.
            while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
            {
                bucket.Timestamps.Dequeue();
            }

            if (bucket.Timestamps.Count >= maxAttemptsPerWindow)
            {
                return false;
            }

            bucket.Timestamps.Enqueue(now);
            return true;
        }
    }

    /// <summary>
    /// Returns the number of recorded attempts for <paramref name="clientKey"/> still
    /// within the trailing window. Primarily for tests / diagnostics; not part of the
    /// hot-path.
    /// </summary>
    /// <param name="clientKey">Opaque caller identifier — typically the remote IP.</param>
    /// <returns>The number of recorded attempts for the key that still fall within the trailing window.</returns>
    public int GetAttemptCount(string clientKey)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(clientKey);
        if (!_buckets.TryGetValue(clientKey.Trim(), out var bucket))
        {
            return 0;
        }

        var cutoff = _timeProvider.GetUtcNow() - _window;
        lock (bucket)
        {
            while (bucket.Timestamps.Count > 0 && bucket.Timestamps.Peek() <= cutoff)
            {
                bucket.Timestamps.Dequeue();
            }

            return bucket.Timestamps.Count;
        }
    }

    /// <summary>
    /// Per-key queue of attempt timestamps. A class (rather than a bare
    /// <see cref="Queue{T}"/>) so the dictionary value identity is stable across
    /// concurrent <see cref="ConcurrentDictionary{TKey,TValue}.GetOrAdd(TKey,Func{TKey,TValue})"/>
    /// races — letting the per-bucket lock guard the queue mutations.
    /// </summary>
    private sealed class AttemptBucket
    {
        /// <summary>Ordered queue of attempt timestamps within the current trailing window.</summary>
        public Queue<DateTimeOffset> Timestamps { get; } = new();
    }
}
