using Microsoft.Extensions.Options;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Transport;
using ZB.MOM.WW.ScadaBridge.Transport.Import;

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

public sealed class BundleSessionStoreTests
{
    private static IOptions<TransportOptions> Options() => Microsoft.Extensions.Options.Options.Create(new TransportOptions());

    private static BundleSession SessionExpiringAt(DateTimeOffset expiresAt) => new()
    {
        SessionId = Guid.NewGuid(),
        Manifest = StubManifest(),
        DecryptedContent = Array.Empty<byte>(),
        ExpiresAt = expiresAt,
    };

    private static BundleManifest StubManifest() => new(
        BundleFormatVersion: 1,
        SchemaVersion: "1.0",
        CreatedAtUtc: DateTimeOffset.UnixEpoch,
        SourceEnvironment: "test",
        ExportedBy: "t",
        ScadaBridgeVersion: "1",
        ContentHash: "0",
        Encryption: null,
        Summary: new BundleSummary(0, 0, 0, 0, 0, 0, 0, 0),
        Contents: Array.Empty<ManifestContentEntry>());

    [Fact]
    public void Open_beyond_MaxConcurrentImportSessions_throws_and_removing_one_admits_a_new_session()
    {
        // #05-T24: the store bounds concurrently-open sessions (each pins a
        // decrypted bundle in memory). The (cap+1)th Open is rejected; freeing a
        // slot re-admits a new session.
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var opts = Options();
        var cap = opts.Value.MaxConcurrentImportSessions;
        var sut = new BundleSessionStore(opts, clock);
        var future = clock.GetUtcNow().AddMinutes(30);

        var sessions = new List<BundleSession>();
        for (var i = 0; i < cap; i++)
        {
            var s = SessionExpiringAt(future);
            sut.Open(s);
            sessions.Add(s);
        }

        var overflow = SessionExpiringAt(future);
        var ex = Assert.Throws<InvalidOperationException>(() => sut.Open(overflow));
        Assert.Contains("Too many concurrent import sessions", ex.Message, StringComparison.Ordinal);

        // Free a slot → the previously-rejected session is admitted.
        sut.Remove(sessions[0].SessionId);
        sut.Open(overflow);
        Assert.NotNull(sut.Get(overflow.SessionId));
    }

    [Fact]
    public void Open_then_Get_returns_session()
    {
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var sut = new BundleSessionStore(Options(), clock);
        var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));

        sut.Open(session);

        var fetched = sut.Get(session.SessionId);
        Assert.NotNull(fetched);
        Assert.Same(session, fetched);
    }

    [Fact]
    public void Get_after_TTL_returns_null_and_evicts()
    {
        var start = DateTimeOffset.UtcNow;
        var clock = new TestTimeProvider(start);
        var sut = new BundleSessionStore(Options(), clock);
        var session = SessionExpiringAt(start.AddMinutes(1));
        sut.Open(session);

        clock.Advance(TimeSpan.FromMinutes(2));

        Assert.Null(sut.Get(session.SessionId));
        // Lookup with no time advancement now returns null again — proves the
        // entry was evicted, not merely filtered.
        Assert.Null(sut.Get(session.SessionId));
    }

    [Fact]
    public void Remove_evicts_session()
    {
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var sut = new BundleSessionStore(Options(), clock);
        var session = SessionExpiringAt(clock.GetUtcNow().AddHours(1));
        sut.Open(session);

        sut.Remove(session.SessionId);

        Assert.Null(sut.Get(session.SessionId));
    }

    [Fact]
    public void EvictExpired_removes_all_past_ttl()
    {
        var start = DateTimeOffset.UtcNow;
        var clock = new TestTimeProvider(start);
        var sut = new BundleSessionStore(Options(), clock);

        var keep = SessionExpiringAt(start.AddMinutes(30));
        var dropA = SessionExpiringAt(start.AddSeconds(10));
        var dropB = SessionExpiringAt(start.AddMinutes(1));
        sut.Open(keep);
        sut.Open(dropA);
        sut.Open(dropB);

        clock.Advance(TimeSpan.FromMinutes(2));
        sut.EvictExpired();

        Assert.NotNull(sut.Get(keep.SessionId));
        Assert.Null(sut.Get(dropA.SessionId));
        Assert.Null(sut.Get(dropB.SessionId));
    }

    [Fact]
    public void Legacy_FailedUnlockAttempts_field_still_round_trips_via_shared_reference()
    {
        // T-003 legacy guard: the per-session FailedUnlockAttempts / Locked fields on
        // BundleSession are no longer the source of truth (lockout moved to the store
        // keyed by ContentHash), but the fields remain as a compatibility shim for
        // callers/tests that still set them directly. The store hands out the shared
        // reference so mutations made on one Get() are observable from another.
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var sut = new BundleSessionStore(Options(), clock);
        var session = SessionExpiringAt(clock.GetUtcNow().AddMinutes(30));
        sut.Open(session);

        var live = sut.Get(session.SessionId);
        Assert.NotNull(live);

        live!.FailedUnlockAttempts++;
        Assert.False(live.Locked);
        live.FailedUnlockAttempts++;
        Assert.False(live.Locked);
        live.FailedUnlockAttempts++;
        Assert.True(live.Locked);

        // Mutations are observable via re-fetch — confirms the store hands out
        // the shared reference (not a snapshot).
        var refetch = sut.Get(session.SessionId);
        Assert.NotNull(refetch);
        Assert.True(refetch!.Locked);
    }

    [Fact]
    public void UnlockFailureCount_TracksPerBundleAndResets()
    {
        // T-003: the per-bundle unlock-failure counter is keyed by ContentHash and
        // shared across sessions, so a second tab / CLI caller cannot side-step the
        // lockout by re-uploading the same bytes. Increment must be atomic and
        // ClearUnlockFailures must drop the entry so a legitimate operator who
        // eventually types the right passphrase is not stuck on a stale count.
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var sut = new BundleSessionStore(Options(), clock);
        const string contentHashA = "sha-A";
        const string contentHashB = "sha-B";

        Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
        Assert.Equal(1, sut.IncrementUnlockFailureCount(contentHashA));
        Assert.Equal(2, sut.IncrementUnlockFailureCount(contentHashA));

        // Per-bundle isolation: another bundle's counter is unaffected.
        Assert.Equal(0, sut.GetUnlockFailureCount(contentHashB));

        sut.ClearUnlockFailures(contentHashA);
        Assert.Equal(0, sut.GetUnlockFailureCount(contentHashA));
    }

    [Fact]
    public void UnlockFailures_ExpireOnTtlAndGetReturnsZero()
    {
        // T-003: failure records share the session TTL so a bundle that was locked
        // hours ago clears on its own. Get must lazily expire stale entries.
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var sut = new BundleSessionStore(Options(), clock);
        const string contentHash = "sha-expiring";

        sut.IncrementUnlockFailureCount(contentHash);
        sut.IncrementUnlockFailureCount(contentHash);
        Assert.Equal(2, sut.GetUnlockFailureCount(contentHash));

        // Advance beyond the configured TTL (default 30 min).
        clock.Advance(TimeSpan.FromMinutes(31));
        Assert.Equal(0, sut.GetUnlockFailureCount(contentHash));
    }

    [Fact]
    public void UnlockFailures_EvictExpired_ClearsStaleEntries()
    {
        // T-003: EvictExpired sweep cleans the per-bundle counters too, not just sessions.
        var clock = new TestTimeProvider(DateTimeOffset.UtcNow);
        var sut = new BundleSessionStore(Options(), clock);
        sut.IncrementUnlockFailureCount("sha-old");

        clock.Advance(TimeSpan.FromMinutes(31));
        sut.EvictExpired();

        Assert.Equal(0, sut.GetUnlockFailureCount("sha-old"));
    }

    /// <summary>
    /// Minimal in-test <see cref="TimeProvider"/> with a mutable clock. Used in
    /// place of <c>Microsoft.Extensions.TimeProvider.Testing</c> (not in the
    /// central package list). Only <see cref="GetUtcNow"/> is overridden; the
    /// store does not use timers, so the base implementations suffice elsewhere.
    /// </summary>
    private sealed class TestTimeProvider : TimeProvider
    {
        private DateTimeOffset _now;
        public TestTimeProvider(DateTimeOffset start) { _now = start; }
        public override DateTimeOffset GetUtcNow() => _now;
        public void Advance(TimeSpan by) => _now = _now.Add(by);
    }
}
