using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Cli.Interactive;
using ZB.MOM.WW.Secrets.Crypto;
using ZB.MOM.WW.Secrets.MasterKey;

namespace ZB.MOM.WW.Secrets.Tests.Cli.Interactive;

/// <summary>
/// Exercises <see cref="ReferenceAuditor"/> against real temp-dir SQLite sessions: distinct-reference
/// collation (one finding per name, every config path listed), the four full-session classifications
/// (present/decryptable → Ok, absent → Missing, tombstoned → Tombstoned, wrong-KEK → Undecryptable),
/// and the degraded-session presence-only downgrade (present → PresentUnverified).
/// </summary>
[Collection("Process environment")]
public sealed class ReferenceAuditorTests : IDisposable
{
    private readonly string _dir =
        Path.Combine(Path.GetTempPath(), $"zb-secrets-audit-{Guid.NewGuid():N}");
    private readonly List<string> _envVars = [];

    private string DbPath => Path.Combine(_dir, "secrets.db");

    public ReferenceAuditorTests() => Directory.CreateDirectory(_dir);

    public void Dispose()
    {
        foreach (string name in _envVars)
        {
            Environment.SetEnvironmentVariable(name, null);
        }

        if (Directory.Exists(_dir))
        {
            try { Directory.Delete(_dir, recursive: true); } catch (IOException) { /* best-effort temp cleanup */ }
        }
    }

    private string SetTempKeyEnv()
    {
        byte[] key = new byte[32];
        RandomNumberGenerator.Fill(key);
        string name = $"ZB_TEST_KEK_{Guid.NewGuid():N}";
        Environment.SetEnvironmentVariable(name, Convert.ToBase64String(key));
        _envVars.Add(name);
        return name;
    }

    private static IConfigurationRoot Config(params (string Key, string Value)[] pairs) =>
        new ConfigurationBuilder()
            .AddInMemoryCollection(pairs.ToDictionary(p => p.Key, p => (string?)p.Value))
            .Build();

    private StoreTarget Target(MasterKeyOptions masterKey, IConfigurationRoot config) =>
        new(
            "test",
            AppSettingsPath: null,
            new SecretsOptions { SqlitePath = DbPath, MasterKey = masterKey },
            SqlServer: null,
            config);

    private MasterKeyOptions EnvKey() => new()
    {
        Source = MasterKeySource.Environment,
        EnvVarName = SetTempKeyEnv(),
    };

    private static MasterKeyOptions UnsetEnvKey() => new()
    {
        Source = MasterKeySource.Environment,
        EnvVarName = $"ZB_TEST_UNSET_{Guid.NewGuid():N}",
    };

    private Task<SecretsSession> Open(MasterKeyOptions masterKey, IConfigurationRoot config) =>
        new SecretsSessionFactory().OpenAsync(Target(masterKey, config), overrideKek: null, CancellationToken.None);

    [Fact]
    public async Task Finds_all_distinct_secret_references_with_their_config_paths()
    {
        IConfigurationRoot config = Config(
            ("Data:Primary:ConnectionString", "Server=db;Password=${secret:db/pw}"),
            ("Data:Replica:ConnectionString", "Server=db2;Password=${secret:db/pw}"),
            ("PlainValue", "not a reference"));

        SecretsSession session = await Open(EnvKey(), config);

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        ReferenceFinding finding = Assert.Single(findings);
        Assert.Equal("db/pw", finding.SecretName);
        Assert.Equal(
            ["Data:Primary:ConnectionString", "Data:Replica:ConnectionString"],
            finding.ConfigPaths);
    }

    [Fact]
    public async Task Classifies_present_decryptable_secret_as_Ok()
    {
        SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));

        var name = new SecretName("api/key");
        await session.Store.UpsertAsync(
            session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        Assert.Equal(ReferenceStatus.Ok, Assert.Single(findings).Status);
    }

    [Fact]
    public async Task Classifies_absent_secret_as_Missing()
    {
        SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        Assert.Equal(ReferenceStatus.Missing, Assert.Single(findings).Status);
    }

    [Fact]
    public async Task Classifies_tombstoned_secret_as_Tombstoned()
    {
        SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));

        var name = new SecretName("api/key");
        await session.Store.UpsertAsync(
            session.Cipher!.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);
        Assert.True(await session.Store.DeleteAsync(name, actor: "test", CancellationToken.None));

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        Assert.Equal(ReferenceStatus.Tombstoned, Assert.Single(findings).Status);
    }

    [Fact]
    public async Task Classifies_wrong_kek_row_as_Undecryptable()
    {
        SecretsSession session = await Open(EnvKey(), Config(("Api:Key", "${secret:api/key}")));

        // Seal a row under a DIFFERENT KEK (KEK-A) and persist it into the session's store; the
        // session's own cipher (KEK-B) then cannot unwrap it → Undecryptable.
        byte[] keyA = new byte[32];
        RandomNumberGenerator.Fill(keyA);
        var cipherA = new AesGcmEnvelopeCipher(new LiteralMasterKeyProvider(Convert.ToBase64String(keyA)));

        var name = new SecretName("api/key");
        await session.Store.UpsertAsync(
            cipherA.Encrypt(name, "s3cret", SecretContentType.Text), CancellationToken.None);

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        Assert.Equal(ReferenceStatus.Undecryptable, Assert.Single(findings).Status);
    }

    [Fact]
    public async Task Malformed_reference_yields_InvalidName_finding_without_aborting_audit()
    {
        SecretsSession session = await Open(EnvKey(), Config(
            ("Bad:Ref", "${secret:REPLACE ME!}"),
            ("Good:Ref", "${secret:api/key}")));

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        Assert.Equal(2, findings.Count);
        Assert.Equal(ReferenceStatus.InvalidName, findings.Single(f => f.SecretName == "REPLACE ME!").Status);
        Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "api/key").Status);
    }

    [Fact]
    public async Task Comment_keys_are_skipped_like_the_expander()
    {
        SecretsSession session = await Open(EnvKey(), Config(
            ("Docs:_comment", "Reference a secret like ${secret:example/thing}"),
            ("Real:Key", "${secret:api/key}")));

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        ReferenceFinding finding = Assert.Single(findings);
        Assert.Equal("api/key", finding.SecretName);
    }

    [Fact]
    public async Task Case_variant_references_collapse_to_one_normalized_finding()
    {
        SecretsSession session = await Open(EnvKey(), Config(
            ("A:Conn", "${secret:Db/Pw}"),
            ("B:Conn", "${secret:db/pw}")));

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(session, CancellationToken.None);

        ReferenceFinding finding = Assert.Single(findings);
        Assert.Equal("db/pw", finding.SecretName);
        Assert.Equal(["A:Conn", "B:Conn"], finding.ConfigPaths);
    }

    [Fact]
    public async Task Degraded_session_reports_PresenceOnly_status()
    {
        // Seed a present row and a tombstoned row through a FULL session first.
        var present = new SecretName("svc/token");
        var gone = new SecretName("old/key");
        SecretsSession seed = await Open(EnvKey(), new ConfigurationBuilder().Build());
        await seed.Store.UpsertAsync(
            seed.Cipher!.Encrypt(present, "tok", SecretContentType.Text), CancellationToken.None);
        await seed.Store.UpsertAsync(
            seed.Cipher!.Encrypt(gone, "old", SecretContentType.Text), CancellationToken.None);
        Assert.True(await seed.Store.DeleteAsync(gone, actor: "test", CancellationToken.None));

        // Re-open the SAME store degraded (KEK unresolvable) and audit references to all three.
        SecretsSession degraded = await Open(UnsetEnvKey(), Config(
            ("A:Token", "${secret:svc/token}"),
            ("B:Old", "${secret:old/key}"),
            ("C:Missing", "${secret:no/such}")));
        Assert.False(degraded.KekAvailable);

        IReadOnlyList<ReferenceFinding> findings =
            await new ReferenceAuditor().AuditAsync(degraded, CancellationToken.None);

        Assert.Equal(ReferenceStatus.PresentUnverified, findings.Single(f => f.SecretName == "svc/token").Status);
        Assert.Equal(ReferenceStatus.Tombstoned, findings.Single(f => f.SecretName == "old/key").Status);
        Assert.Equal(ReferenceStatus.Missing, findings.Single(f => f.SecretName == "no/such").Status);
    }
}
