using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;

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

/// <summary>
/// In-memory implementation of <see cref="IBundleSessionStore"/> backed by a
/// <see cref="ConcurrentDictionary{TKey,TValue}"/>. Sessions are evicted lazily
/// at read time (<see cref="Get"/>) and on-demand via <see cref="EvictExpired"/>;
/// there is no background timer.
/// <para>
/// Thread-safety: backed by <see cref="ConcurrentDictionary{TKey,TValue}"/> of
/// <see cref="Guid"/> to <see cref="BundleSession"/>. All store operations
/// (<see cref="Get"/> / <see cref="Open"/> / <see cref="Remove"/> /
/// <see cref="EvictExpired"/>) use the concurrent dictionary's safe primitives
/// (<c>TryGetValue</c>, indexer assignment, <c>TryRemove</c>) and are safe
/// under concurrent callers. The <see cref="BundleSession"/> instance itself
/// is NOT thread-safe — callers that share a session reference (e.g. two
/// importers mutating <c>FailedUnlockAttempts</c> on the same session) MUST
/// serialize their mutations on that shared reference.
/// </para>
/// <para>
/// TTL is supplied by the importer via <see cref="BundleSession.ExpiresAt"/>;
/// this store does not impose its own. The injected <see cref="TimeProvider"/>
/// is used purely to determine <c>now</c> when checking <c>ExpiresAt</c>, which
/// keeps unit tests deterministic.
/// </para>
/// <para>
/// The 3-strike unlock lockout is owned by <see cref="BundleSession"/>
/// (<c>FailedUnlockAttempts</c> / <c>Locked</c>); the store just hands out the
/// shared session reference so the importer can mutate the counter in place.
/// </para>
/// </summary>
public sealed class BundleSessionStore : IBundleSessionStore
{
    private readonly ConcurrentDictionary<Guid, BundleSession> _sessions = new();

    /// <summary>
    /// T-003: per-bundle unlock-failure counters, keyed by <see cref="BundleManifest.ContentHash"/>
    /// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on
    /// <see cref="BundleSession"/> — so retries against the same bundle bytes from a
    /// second tab / CLI caller share the counter and cannot side-step the lockout. Entries
    /// expire on the same TTL as a session.
    /// </summary>
    private readonly ConcurrentDictionary<string, UnlockFailureRecord> _unlockFailures = new(StringComparer.OrdinalIgnoreCase);

    private readonly TimeProvider _timeProvider;
    private readonly IOptions<TransportOptions> _options;

    /// <summary>T-003: per-bundle unlock-failure entry with expiry.</summary>
    private sealed class UnlockFailureRecord
    {
        public int Count;
        public DateTimeOffset ExpiresAt;
    }

    /// <summary>
    /// Initializes a new <see cref="BundleSessionStore"/>.
    /// </summary>
    /// <param name="options">Transport options. <see cref="TransportOptions.BundleSessionTtlMinutes"/> is also used as the TTL for the T-003 per-bundle unlock-failure tracker.</param>
    /// <param name="timeProvider">Time provider used to evaluate session expiry.</param>
    public BundleSessionStore(IOptions<TransportOptions> options, TimeProvider timeProvider)
    {
        ArgumentNullException.ThrowIfNull(options);
        _options = options;
        _ = options.Value;
        _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
    }

    /// <inheritdoc />
    public BundleSession Open(BundleSession session)
    {
        ArgumentNullException.ThrowIfNull(session);
        if (!_sessions.ContainsKey(session.SessionId))
        {
            var cap = _options.Value.MaxConcurrentImportSessions;
            if (_sessions.Count >= cap)
            {
                // A full store may be full only of stale entries — reclaim first.
                EvictExpired();
                if (_sessions.Count >= cap)
                {
                    throw new InvalidOperationException(
                        $"Too many concurrent import sessions ({cap}). Complete or cancel an in-progress import before starting another.");
                }
            }
        }
        // Overwrite on collision is defensive: GUIDs are random so practical
        // collisions don't happen, but if a caller reuses an id we always
        // honor the latest Open call.
        _sessions[session.SessionId] = session;
        return session;
    }

    /// <inheritdoc />
    public BundleSession? Get(Guid sessionId)
    {
        if (!_sessions.TryGetValue(sessionId, out var session)) return null;
        if (session.ExpiresAt > _timeProvider.GetUtcNow()) return session;
        _sessions.TryRemove(sessionId, out _);
        return null;
    }

    /// <inheritdoc />
    public void Remove(Guid sessionId)
    {
        _sessions.TryRemove(sessionId, out _);
    }

    /// <inheritdoc />
    public void EvictExpired()
    {
        var now = _timeProvider.GetUtcNow();
        foreach (var kv in _sessions)
        {
            if (kv.Value.ExpiresAt <= now)
            {
                _sessions.TryRemove(kv.Key, out _);
            }
        }

        // T-003: also expire stale per-bundle unlock-failure entries so a bundle
        // that was previously locked clears once the lockout window passes.
        foreach (var kv in _unlockFailures)
        {
            if (kv.Value.ExpiresAt <= now)
            {
                _unlockFailures.TryRemove(kv.Key, out _);
            }
        }
    }

    /// <inheritdoc />
    public int GetUnlockFailureCount(string bundleContentHash)
    {
        ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
        if (!_unlockFailures.TryGetValue(bundleContentHash, out var record))
        {
            return 0;
        }

        // Lazy expiry — if the entry has aged past its window treat it as cleared.
        if (record.ExpiresAt <= _timeProvider.GetUtcNow())
        {
            _unlockFailures.TryRemove(bundleContentHash, out _);
            return 0;
        }

        return record.Count;
    }

    /// <inheritdoc />
    public int IncrementUnlockFailureCount(string bundleContentHash)
    {
        ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
        var ttl = TimeSpan.FromMinutes(_options.Value.BundleSessionTtlMinutes);
        var now = _timeProvider.GetUtcNow();
        var record = _unlockFailures.AddOrUpdate(
            bundleContentHash,
            _ => new UnlockFailureRecord { Count = 1, ExpiresAt = now + ttl },
            (_, existing) =>
            {
                // Treat an expired record as a fresh start so a legitimate operator
                // returning hours later does not face a stale lockout.
                if (existing.ExpiresAt <= now)
                {
                    existing.Count = 1;
                }
                else
                {
                    existing.Count++;
                }

                existing.ExpiresAt = now + ttl;
                return existing;
            });

        return record.Count;
    }

    /// <inheritdoc />
    public void ClearUnlockFailures(string bundleContentHash)
    {
        ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
        _unlockFailures.TryRemove(bundleContentHash, out _);
    }
}
