using System.Security.Cryptography;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Secrets.Abstractions;
using ZB.MOM.WW.Secrets.Configuration;
using ZB.MOM.WW.Secrets.DependencyInjection;
using ZB.MOM.WW.Secrets.Sqlite;

namespace ZB.MOM.WW.MxGateway.Tests.Configuration;

/// <summary>
/// Exercises the exact pre-host <c>${secret:}</c> expansion flow that
/// <see cref="ZB.MOM.WW.MxGateway.Server.GatewayApplication.CreateBuilder"/> runs before the host is
/// built: a throwaway <c>AddZbSecrets</c> container migrates the SQLite store, then a
/// <see cref="SecretReferenceExpander"/> rewrites configuration tokens in place. A seeded reference
/// resolves to its plaintext; a reference to an absent secret is fail-closed
/// (<see cref="SecretNotFoundException"/>).
/// </summary>
public sealed class PreHostSecretExpansionTests
{
    [Fact]
    public async Task ExpandConfiguration_SeededReference_ResolvesToPlaintext()
    {
        using var fixture = SecretsFixture.Create();
        const string plaintext = "s3cr3t-plaintext-value";
        await fixture.SeedAsync("test/value", plaintext);

        IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:test/value}"));

        await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default);

        Assert.Equal(plaintext, config["MxGateway:Ldap:ServiceAccountPassword"]);
    }

    /// <summary>
    /// G-4: the shipped <c>appsettings.json</c> sources the LDAP bind password from
    /// <c>${secret:ldap/mxgateway/bind}</c>. Proves that exact reference at that exact key resolves to
    /// the seeded plaintext through the same pre-host expander the host runs.
    /// </summary>
    [Fact]
    public async Task ExpandConfiguration_LdapBindReference_ResolvesToSeededPassword()
    {
        using var fixture = SecretsFixture.Create();
        const string bindPassword = "seeded-bind-password";
        await fixture.SeedAsync("ldap/mxgateway/bind", bindPassword);

        IConfigurationRoot config = fixture.BuildConfig(
            ("MxGateway:Ldap:ServiceAccountPassword", "${secret:ldap/mxgateway/bind}"));

        await new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default);

        Assert.Equal(bindPassword, config["MxGateway:Ldap:ServiceAccountPassword"]);
    }

    [Fact]
    public async Task ExpandConfiguration_MissingReference_ThrowsFailClosed()
    {
        using var fixture = SecretsFixture.Create();

        IConfigurationRoot config = fixture.BuildConfig(("MxGateway:Ldap:ServiceAccountPassword", "${secret:missing}"));

        await Assert.ThrowsAsync<SecretNotFoundException>(
            () => new SecretReferenceExpander(fixture.Resolver).ExpandConfigurationAsync(config, default));
    }

    /// <summary>
    /// A self-contained secrets store: a unique temp SQLite path + a per-test master-key env var
    /// (so parallel tests never collide), wired through the real <c>AddZbSecrets</c> DI graph the
    /// host uses. Disposal removes the temp file and clears the env var.
    /// </summary>
    private sealed class SecretsFixture : IDisposable
    {
        private readonly ServiceProvider _provider;
        private readonly string _sqlitePath;
        private readonly string _envVarName;

        private SecretsFixture(ServiceProvider provider, string sqlitePath, string envVarName)
        {
            _provider = provider;
            _sqlitePath = sqlitePath;
            _envVarName = envVarName;
        }

        public ISecretResolver Resolver => _provider.GetRequiredService<ISecretResolver>();

        public static SecretsFixture Create()
        {
            string unique = Guid.NewGuid().ToString("N");
            string sqlitePath = Path.Combine(Path.GetTempPath(), $"mxgw-secrets-{unique}.db");
            string envVarName = $"ZB_SECRETS_MASTER_KEY_TEST_{unique}";
            Environment.SetEnvironmentVariable(envVarName, Convert.ToBase64String(RandomNumberGenerator.GetBytes(32)));

            IConfigurationRoot secretsConfig = new ConfigurationBuilder()
                .AddInMemoryCollection(new Dictionary<string, string?>
                {
                    ["Secrets:SqlitePath"] = sqlitePath,
                    ["Secrets:MasterKey:Source"] = "Environment",
                    ["Secrets:MasterKey:EnvVarName"] = envVarName,
                    ["Secrets:RunMigrationsOnStartup"] = "true",
                    ["Secrets:ResolveCacheTtl"] = "00:00:30",
                })
                .Build();

            ServiceProvider provider = new ServiceCollection()
                .AddZbSecrets(secretsConfig, "Secrets")
                .BuildServiceProvider();

            // Mirror the pre-host path: migrate the store explicitly before the first resolve.
            provider.GetRequiredService<SqliteSecretsStoreMigrator>().MigrateAsync(default).GetAwaiter().GetResult();

            return new SecretsFixture(provider, sqlitePath, envVarName);
        }

        // Seals a plaintext value under a fresh DEK and upserts it — the same store/cipher pair the
        // CLI 'secret set' verb uses.
        public async Task SeedAsync(string name, string plaintext)
        {
            var secretName = new SecretName(name);
            ISecretCipher cipher = _provider.GetRequiredService<ISecretCipher>();
            ISecretStore store = _provider.GetRequiredService<ISecretStore>();
            StoredSecret sealed_ = cipher.Encrypt(secretName, plaintext, SecretContentType.Text);
            await store.UpsertAsync(sealed_, default);
        }

        public IConfigurationRoot BuildConfig(params (string Key, string Value)[] entries) =>
            new ConfigurationBuilder()
                .AddInMemoryCollection(entries.ToDictionary(e => e.Key, e => (string?)e.Value))
                .Build();

        public void Dispose()
        {
            _provider.Dispose();
            Environment.SetEnvironmentVariable(_envVarName, null);

            // The SQLite store runs in WAL mode with connection pooling, so a pooled handle can
            // outlive _provider.Dispose() and keep the .db (plus its -wal/-shm sidecars) open. Clear
            // the pool first, then delete all three files so no temp artifact leaks between tests.
            Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
            foreach (string path in new[] { _sqlitePath, _sqlitePath + "-wal", _sqlitePath + "-shm" })
            {
                try
                {
                    if (File.Exists(path))
                    {
                        File.Delete(path);
                    }
                }
                catch (IOException)
                {
                    // Best-effort cleanup of the temp store; a locked file must not fail the test.
                }
            }
        }
    }
}
