using System.Collections.Concurrent;
using Microsoft.Extensions.Options;

namespace ZB.MOM.WW.ScadaBridge.Security;

/// <summary>
/// Fixed-window LDAP-bind failure lockout, keyed per <c>{username}|{ip}</c>.
/// </summary>
/// <remarks>
/// <para>
/// Every LDAP-bind surface (the Central UI <c>/auth/login</c> and <c>/auth/token</c>
/// endpoints, and the ManagementAuthenticator-fronted <c>POST /management</c>, audit REST,
/// and debug hub) records bind outcomes here and consults <see cref="IsLockedOut"/> before
/// attempting a bind. This blunts online password-spray / brute-force against the directory
/// without any external state — it is an in-memory, best-effort throttle per central node
/// (the two central nodes maintain independent counters; that is acceptable, since a
/// determined spray hitting both nodes still burns its budget twice as fast on each).
/// </para>
/// <para>
/// The counter uses a <b>fixed window</b>: the first failure opens a window of
/// <see cref="SecurityOptions.LoginFailureWindowMinutes"/>; reaching
/// <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/> failures inside that window locks
/// the key out for <see cref="SecurityOptions.LoginLockoutMinutes"/>. A successful bind or an
/// expired window resets the counter. Setting <see cref="SecurityOptions.MaxLoginFailuresPerWindow"/>
/// to 0 disables throttling entirely.
/// </para>
/// <para>
/// Registered as a singleton (<see cref="ServiceCollectionExtensions.AddSecurity"/>) so the
/// counters are shared across all requests on a node. Thread-safe via
/// <see cref="ConcurrentDictionary{TKey,TValue}"/> and atomic entry swaps. Memory is bounded:
/// each write opportunistically prunes expired entries, and if the map still exceeds
/// <see cref="MaxTrackedKeys"/> the oldest-windowed entries are dropped — so a spray across a
/// large IP/username space cannot grow the map without limit.
/// </para>
/// </remarks>
public sealed class LoginThrottle
{
    /// <summary>
    /// Hard cap on the number of tracked <c>{username}|{ip}</c> keys. Above this the
    /// oldest-windowed entries are dropped on write to bound memory under a spray attack.
    /// </summary>
    public const int MaxTrackedKeys = 10_000;

    /// <summary>Amortization cadence: a full prune sweep runs every Nth failure write
    /// (plus immediately whenever the map exceeds <see cref="MaxTrackedKeys"/>), so a
    /// spray does not pay an O(n) scan — and a possible full sort — on every failed
    /// request (arch-review R2 N8). Lockout SEMANTICS are unaffected: IsLockedOut reads
    /// window/lockout expiry directly and never depends on pruning.</summary>
    internal const int PruneEveryNFailures = 64;

    /// <summary>Diagnostics/test accessor: number of currently tracked keys.</summary>
    internal int TrackedKeyCount => _entries.Count;

    private int _failureWrites;

    private readonly TimeProvider _clock;
    private readonly IOptions<SecurityOptions> _options;

    private readonly ConcurrentDictionary<string, Entry> _entries = new(StringComparer.Ordinal);

    /// <summary>
    /// One tracked key's state: the failure <paramref name="Count"/>, the
    /// <paramref name="WindowStart"/> that anchors the fixed failure window, and — once the
    /// key is locked — the <paramref name="LockedUntil"/> instant the lockout expires.
    /// </summary>
    private readonly record struct Entry(int Count, DateTimeOffset WindowStart, DateTimeOffset? LockedUntil);

    /// <summary>Creates the throttle over the injected clock and security options.</summary>
    /// <param name="clock">Time source (wall clock in production; a fake in tests).</param>
    /// <param name="options">Security options carrying the throttle thresholds.</param>
    public LoginThrottle(TimeProvider clock, IOptions<SecurityOptions> options)
    {
        _clock = clock;
        _options = options;
    }

    /// <summary>
    /// Returns true when the given <paramref name="username"/> + <paramref name="ip"/> pair is
    /// currently locked out and a bind must be refused without contacting LDAP. Never mutates
    /// state (an expired lockout simply reads as unlocked; the entry is cleared on the next write).
    /// </summary>
    /// <param name="username">The username being authenticated.</param>
    /// <param name="ip">The client IP address of the bind attempt.</param>
    /// <returns><c>true</c> when the key is currently locked out; otherwise <c>false</c>.</returns>
    public bool IsLockedOut(string username, string ip)
    {
        var opts = _options.Value;
        if (opts.MaxLoginFailuresPerWindow <= 0) return false; // throttling disabled

        if (!_entries.TryGetValue(Key(username, ip), out var entry)) return false;

        return entry.LockedUntil is { } until && until > _clock.GetUtcNow();
    }

    /// <summary>
    /// Records a failed bind for the given <paramref name="username"/> + <paramref name="ip"/>.
    /// Opens or advances the fixed window and, on reaching the configured threshold, arms the
    /// lockout. No-op when throttling is disabled.
    /// </summary>
    /// <param name="username">The username that failed authentication.</param>
    /// <param name="ip">The client IP address of the failed bind attempt.</param>
    public void RecordFailure(string username, string ip)
    {
        var opts = _options.Value;
        if (opts.MaxLoginFailuresPerWindow <= 0) return; // throttling disabled

        var now = _clock.GetUtcNow();
        var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);
        var lockout = TimeSpan.FromMinutes(opts.LoginLockoutMinutes);
        var key = Key(username, ip);

        _entries.AddOrUpdate(
            key,
            // First observed failure for this key: open a fresh window.
            _ => Arm(new Entry(1, now, null), opts, now, lockout),
            (_, existing) =>
            {
                // A live lockout stands until it expires — further failures don't extend it.
                if (existing.LockedUntil is { } until && until > now)
                {
                    return existing;
                }

                // If the current window has lapsed (or a prior lockout has expired), restart it.
                if (existing.LockedUntil is not null || now - existing.WindowStart >= window)
                {
                    return Arm(new Entry(1, now, null), opts, now, lockout);
                }

                return Arm(existing with { Count = existing.Count + 1 }, opts, now, lockout);
            });

        // Amortized prune (R2 N8): every Nth failure, or immediately when over the hard
        // cap — the cap is the memory-safety invariant and must never wait for the cadence.
        if (Interlocked.Increment(ref _failureWrites) % PruneEveryNFailures == 0
            || _entries.Count > MaxTrackedKeys)
        {
            Prune(now);
        }
    }

    /// <summary>
    /// Records a successful bind for the given <paramref name="username"/> +
    /// <paramref name="ip"/>, clearing any accumulated failure count and lockout for that key.
    /// </summary>
    /// <param name="username">The username that authenticated successfully.</param>
    /// <param name="ip">The client IP address of the successful bind attempt.</param>
    public void RecordSuccess(string username, string ip)
    {
        _entries.TryRemove(Key(username, ip), out _);
    }

    /// <summary>Arms the lockout on <paramref name="entry"/> if it has reached the threshold.</summary>
    private static Entry Arm(Entry entry, SecurityOptions opts, DateTimeOffset now, TimeSpan lockout) =>
        entry.Count >= opts.MaxLoginFailuresPerWindow
            ? entry with { LockedUntil = now + lockout }
            : entry;

    /// <summary>
    /// Opportunistic memory management on write: drop entries whose window has fully expired
    /// (no active lockout), and if the map still exceeds <see cref="MaxTrackedKeys"/> evict the
    /// oldest-windowed entries so a spray across many keys cannot grow the map unbounded.
    /// </summary>
    private void Prune(DateTimeOffset now)
    {
        var opts = _options.Value;
        var window = TimeSpan.FromMinutes(opts.LoginFailureWindowMinutes);

        foreach (var kvp in _entries)
        {
            var e = kvp.Value;
            var lockActive = e.LockedUntil is { } until && until > now;
            if (!lockActive && now - e.WindowStart >= window)
            {
                _entries.TryRemove(kvp);
            }
        }

        if (_entries.Count <= MaxTrackedKeys) return;

        // Still over the cap after pruning expired entries — evict the oldest-windowed keys.
        var excess = _entries.Count - MaxTrackedKeys;
        foreach (var kvp in _entries.OrderBy(k => k.Value.WindowStart).Take(excess))
        {
            _entries.TryRemove(kvp);
        }
    }

    private static string Key(string username, string ip) =>
        $"{(username ?? string.Empty).ToLowerInvariant()}|{ip ?? string.Empty}";
}
