using System.Collections.Concurrent;
using Microsoft.Extensions.Options;
using ScadaLink.Commons.Interfaces.Transport;
using ScadaLink.Commons.Types.Transport;
namespace ScadaLink.Transport.Import;
///
/// In-memory implementation of backed by a
/// . Sessions are evicted lazily
/// at read time () and on-demand via ;
/// there is no background timer.
///
/// Thread-safety: backed by of
/// to . All store operations
/// ( / / /
/// ) use the concurrent dictionary's safe primitives
/// (TryGetValue, indexer assignment, TryRemove) and are safe
/// under concurrent callers. The instance itself
/// is NOT thread-safe — callers that share a session reference (e.g. two
/// importers mutating FailedUnlockAttempts on the same session) MUST
/// serialize their mutations on that shared reference.
///
///
/// TTL is supplied by the importer via ;
/// this store does not impose its own. The injected
/// is used purely to determine now when checking ExpiresAt, which
/// keeps unit tests deterministic.
///
///
/// The 3-strike unlock lockout is owned by
/// (FailedUnlockAttempts / Locked); the store just hands out the
/// shared session reference so the importer can mutate the counter in place.
///
///
public sealed class BundleSessionStore : IBundleSessionStore
{
private readonly ConcurrentDictionary _sessions = new();
///
/// T-003: per-bundle unlock-failure counters, keyed by
/// (SHA-256 hex of the bundle's content bytes). Failures are tracked here — not on
/// — 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.
///
private readonly ConcurrentDictionary _unlockFailures = new(StringComparer.OrdinalIgnoreCase);
private readonly TimeProvider _timeProvider;
private readonly IOptions _options;
/// T-003: per-bundle unlock-failure entry with expiry.
private sealed class UnlockFailureRecord
{
public int Count;
public DateTimeOffset ExpiresAt;
}
///
/// Initializes a new .
///
/// Transport options. is also used as the TTL for the T-003 per-bundle unlock-failure tracker.
/// Time provider used to evaluate session expiry.
public BundleSessionStore(IOptions options, TimeProvider timeProvider)
{
ArgumentNullException.ThrowIfNull(options);
_options = options;
_ = options.Value;
_timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
}
///
public BundleSession Open(BundleSession session)
{
ArgumentNullException.ThrowIfNull(session);
// 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;
}
///
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;
}
///
public void Remove(Guid sessionId)
{
_sessions.TryRemove(sessionId, out _);
}
///
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 _);
}
}
}
///
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;
}
///
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;
}
///
public void ClearUnlockFailures(string bundleContentHash)
{
ArgumentException.ThrowIfNullOrEmpty(bundleContentHash);
_unlockFailures.TryRemove(bundleContentHash, out _);
}
}