using System.Text.Json;
using ZB.MOM.WW.Audit;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.Tests.Fakes;

namespace ZB.MOM.WW.Secrets.Tests;

public class DefaultSecretResolverTests
{
    private static readonly TimeSpan Ttl = TimeSpan.FromSeconds(30);

    /// <summary>
    /// Builds a real encrypted row for <paramref name="plaintext"/> using the real cipher, then
    /// stamps a revision + timestamps (the cipher leaves those at defaults) and optionally tombstones
    /// it, so tests exercise the true decrypt path rather than a fake.
    /// </summary>
    private static StoredSecret MakeRow(
        AesGcmEnvelopeCipher cipher, SecretName name, string plaintext, bool deleted = false)
    {
        StoredSecret row = cipher.Encrypt(name, plaintext, SecretContentType.Text);
        return row with
        {
            Revision = 1,
            CreatedUtc = DateTimeOffset.UnixEpoch,
            UpdatedUtc = DateTimeOffset.UnixEpoch,
            IsDeleted = deleted,
            DeletedUtc = deleted ? DateTimeOffset.UnixEpoch : null,
        };
    }

    private static (DefaultSecretResolver resolver, CountingSecretStore store, CapturingAuditWriter audit,
        AesGcmEnvelopeCipher cipher, MutableTimeProvider clock) NewSut(ISecretActorAccessor? actor = null)
    {
        var cipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
        var store = new CountingSecretStore();
        var audit = new CapturingAuditWriter();
        var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch);
        var resolver = new DefaultSecretResolver(store, cipher, audit, Ttl, actor, clock);
        return (resolver, store, audit, cipher, clock);
    }

    [Fact]
    public async Task Get_ReturnsDecryptedPlaintext_AndAuditsSuccess()
    {
        var (resolver, store, audit, cipher, _) = NewSut();
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        string? value = await resolver.GetAsync(name, CancellationToken.None);

        Assert.Equal("hunter2", value);
        AuditEvent evt = Assert.Single(audit.Events);
        Assert.Equal(AuditOutcome.Success, evt.Outcome);
        Assert.Equal("sql/foo", evt.Target);

        // Hard guarantee: the plaintext must NEVER appear in any serialized audit field.
        foreach (AuditEvent captured in audit.Events)
        {
            string json = JsonSerializer.Serialize(captured);
            Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal);
        }
    }

    [Fact]
    public async Task Get_CachesWithinTtl()
    {
        var (resolver, store, _, cipher, _) = NewSut();
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        string? first = await resolver.GetAsync(name, CancellationToken.None);
        string? second = await resolver.GetAsync(name, CancellationToken.None);

        Assert.Equal("hunter2", first);
        Assert.Equal("hunter2", second);
        Assert.Equal(1, store.GetCount); // second call served from the TTL cache
    }

    [Fact]
    public async Task Get_ReloadsAfterTtlExpiry()
    {
        var (resolver, store, _, cipher, clock) = NewSut();
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        await resolver.GetAsync(name, CancellationToken.None);
        clock.Advance(Ttl + TimeSpan.FromSeconds(1)); // past the TTL
        await resolver.GetAsync(name, CancellationToken.None);

        Assert.Equal(2, store.GetCount);
    }

    [Fact]
    public async Task Get_MissingSecret_ReturnsNull_AuditsFailure()
    {
        var (resolver, _, audit, _, _) = NewSut();

        string? value = await resolver.GetAsync(new SecretName("sql/absent"), CancellationToken.None);

        Assert.Null(value);
        AuditEvent evt = Assert.Single(audit.Events);
        Assert.Equal(AuditOutcome.Failure, evt.Outcome);
        Assert.Contains("not-found", evt.Action, StringComparison.Ordinal);
    }

    [Fact]
    public async Task Get_TombstonedSecret_ReturnsNull()
    {
        var (resolver, store, audit, cipher, _) = NewSut();
        var name = new SecretName("sql/dead");
        store.Seed(MakeRow(cipher, name, "hunter2", deleted: true));

        string? value = await resolver.GetAsync(name, CancellationToken.None);

        Assert.Null(value);
        AuditEvent evt = Assert.Single(audit.Events);
        Assert.Equal(AuditOutcome.Failure, evt.Outcome);
    }

    [Fact]
    public async Task Get_DecryptionFailure_AuditsFailure_AndRethrows()
    {
        // The resolver's cipher and the encrypting cipher share a KekId ("k1") but hold different
        // random key bytes, so the DEK unwrap fails closed with a SecretDecryptionException.
        var store = new CountingSecretStore();
        var audit = new CapturingAuditWriter();
        var clock = new MutableTimeProvider(DateTimeOffset.UnixEpoch);
        var resolverCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1"));
        var resolver = new DefaultSecretResolver(store, resolverCipher, audit, Ttl, actorAccessor: null, clock);

        var foreignCipher = new AesGcmEnvelopeCipher(new FakeMasterKeyProvider("k1")); // same id, different key
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(foreignCipher, name, "hunter2"));

        // Fail-loud: the integrity failure must propagate, not be masked as a benign miss/null.
        await Assert.ThrowsAsync<SecretDecryptionException>(() => resolver.GetAsync(name, CancellationToken.None));

        AuditEvent evt = Assert.Single(audit.Events);
        Assert.Equal(AuditOutcome.Failure, evt.Outcome);
        Assert.Equal("secret.resolve.decryption-failed", evt.Action);
        Assert.Equal("sql/foo", evt.Target);

        // Hard guarantee: the plaintext must NEVER appear in any serialized audit field.
        foreach (AuditEvent captured in audit.Events)
        {
            string json = JsonSerializer.Serialize(captured);
            Assert.DoesNotContain("hunter2", json, StringComparison.Ordinal);
        }
    }

    [Fact]
    public async Task Invalidate_RemovesCacheEntry()
    {
        var (resolver, store, _, cipher, _) = NewSut();
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        await resolver.GetAsync(name, CancellationToken.None); // caches (count 1)
        resolver.Invalidate(name);
        await resolver.GetAsync(name, CancellationToken.None); // cache gone → reload (count 2)

        Assert.Equal(2, store.GetCount);
    }

    [Fact]
    public async Task Invalidate_ViaCacheInvalidatorSeam_ForcesReload()
    {
        var (resolver, store, _, cipher, _) = NewSut();
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        // Exercise the SAME seam the write path (Ui rotate/delete) uses: ISecretCacheInvalidator.
        ISecretCacheInvalidator invalidator = resolver;

        await resolver.GetAsync(name, CancellationToken.None); // caches (count 1)
        invalidator.Invalidate(name);
        await resolver.GetAsync(name, CancellationToken.None); // cache evicted → hits the store again

        Assert.Equal(2, store.GetCount);
    }

    [Fact]
    public async Task Get_DefaultActorIsSystem_WhenNoAccessor()
    {
        var (resolver, store, audit, cipher, _) = NewSut(actor: null);
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        await resolver.GetAsync(name, CancellationToken.None);

        Assert.Equal("system", Assert.Single(audit.Events).Actor);
    }

    [Fact]
    public async Task Get_UsesActorFromAccessor()
    {
        var (resolver, store, audit, cipher, _) = NewSut(new FixedActorAccessor("alice"));
        var name = new SecretName("sql/foo");
        store.Seed(MakeRow(cipher, name, "hunter2"));

        await resolver.GetAsync(name, CancellationToken.None);

        Assert.Equal("alice", Assert.Single(audit.Events).Actor);
    }
}
