using Microsoft.AspNetCore.Builder;
using Microsoft.Data.Sqlite;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;
using ZB.MOM.WW.MxGateway.Server;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;

namespace ZB.MOM.WW.MxGateway.Tests.Security.Authentication;

/// <summary>
/// Parity tests for the shared <c>ZB.MOM.WW.Auth.ApiKeys</c> SQLite store as wired by the gateway.
/// The gateway is the donor this store was extracted from; these tests pin that existing deployed
/// <c>gateway-auth.db</c> databases (schema version 2, same tables/columns/scopes encoding) remain
/// readable and that migration is idempotent and refuses a newer on-disk schema.
/// </summary>
public sealed class SqliteAuthStoreTests : IDisposable
{
    private readonly List<TempDatabaseDirectory> _tempDirectories = [];

    /// <summary>
    /// Verifies that MigrateAsync initializes the database schema at the donor's version (2).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task MigrateAsync_EmptyDatabase_InitializesCurrentSchema()
    {
        string databasePath = CreateTempDatabasePath();
        await using ServiceProvider services = BuildAuthServices(databasePath);

        SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();

        await migrator.MigrateAsync(CancellationToken.None);

        Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadSchemaVersionAsync(databasePath));
        Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeysTable));
        Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
    }

    /// <summary>
    /// Verifies that MigrateAsync migrates and is idempotent.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task MigrateAsync_ExistingVersionZeroDatabase_MigratesIdempotently()
    {
        string databasePath = CreateTempDatabasePath();
        await CreateVersionZeroDatabaseAsync(databasePath);
        await using ServiceProvider services = BuildAuthServices(databasePath);

        SqliteAuthStoreMigrator migrator = services.GetRequiredService<SqliteAuthStoreMigrator>();

        await migrator.MigrateAsync(CancellationToken.None);
        await migrator.MigrateAsync(CancellationToken.None);

        Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadSchemaVersionAsync(databasePath));
        Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeysTable));
        Assert.True(await TableExistsAsync(databasePath, SqliteAuthSchema.ApiKeyAuditTable));
    }

    /// <summary>
    /// Verifies that gateway startup fails with a newer schema version.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task StartAsync_NewerSchemaVersion_BlocksStartup()
    {
        string databasePath = CreateTempDatabasePath();
        await CreateSchemaVersionDatabaseAsync(databasePath, SqliteAuthSchema.CurrentVersion + 1);

        await using WebApplication app = GatewayApplication.Build(
            [
                $"--MxGateway:Authentication:SqlitePath={databasePath}",
                "--urls=http://127.0.0.1:0"
            ]);

        AuthStoreMigrationException exception = await Assert.ThrowsAsync<AuthStoreMigrationException>(
            () => app.StartAsync(CancellationToken.None));

        Assert.Contains("newer than supported version", exception.Message, StringComparison.Ordinal);
    }

    /// <summary>
    /// Verifies that FindActiveByKeyIdAsync returns an active key, reading a row whose columns match
    /// the donor schema (peppered secret_hash BLOB, ordinal-sorted scopes JSON).
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task FindActiveByKeyIdAsync_ExistingActiveKey_ReturnsKey()
    {
        string databasePath = CreateTempDatabasePath();
        await using ServiceProvider services = BuildAuthServices(databasePath);
        await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
        await InsertApiKeyAsync(databasePath, revokedUtc: null);

        IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();

        ApiKeyRecord? key = await store.FindActiveByKeyIdAsync("test-key", CancellationToken.None);

        Assert.NotNull(key);
        Assert.Equal("test-key", key.KeyId);
        Assert.Equal("mxgw_test", key.KeyPrefix);
        Assert.Equal([1, 2, 3, 4], key.SecretHash);
        Assert.Contains("session:open", key.Scopes);
        Assert.Null(key.RevokedUtc);
    }

    /// <summary>
    /// Verifies that FindActiveByKeyIdAsync returns null for a revoked key.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task FindActiveByKeyIdAsync_RevokedKey_ReturnsNull()
    {
        string databasePath = CreateTempDatabasePath();
        await using ServiceProvider services = BuildAuthServices(databasePath);
        await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);
        await InsertApiKeyAsync(databasePath, DateTimeOffset.UtcNow);

        IApiKeyStore store = services.GetRequiredService<IApiKeyStore>();

        ApiKeyRecord? activeKey = await store.FindActiveByKeyIdAsync(
            "test-key",
            CancellationToken.None);
        ApiKeyRecord? storedKey = await store.FindByKeyIdAsync("test-key", CancellationToken.None);

        Assert.Null(activeKey);
        Assert.NotNull(storedKey);
        Assert.NotNull(storedKey.RevokedUtc);
    }

    /// <summary>
    /// Verifies that the audit store persists audit events.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task ApiKeyAuditStore_AppendAsync_PersistsAuditEvent()
    {
        string databasePath = CreateTempDatabasePath();
        await using ServiceProvider services = BuildAuthServices(databasePath);
        await services.GetRequiredService<SqliteAuthStoreMigrator>().MigrateAsync(CancellationToken.None);

        IApiKeyAuditStore auditStore = services.GetRequiredService<IApiKeyAuditStore>();

        await auditStore.AppendAsync(
            new ApiKeyAuditEntry(
                KeyId: "test-key",
                EventType: "lookup",
                RemoteAddress: "127.0.0.1",
                CreatedUtc: DateTimeOffset.UtcNow,
                Details: "matched active key"),
            CancellationToken.None);

        IReadOnlyList<ApiKeyAuditEntry> records = await auditStore.ListRecentAsync(
            10,
            CancellationToken.None);

        ApiKeyAuditEntry record = Assert.Single(records);
        Assert.Equal("test-key", record.KeyId);
        Assert.Equal("lookup", record.EventType);
        Assert.Equal("127.0.0.1", record.RemoteAddress);
        Assert.Equal("matched active key", record.Details);
    }

    /// <summary>
    /// Verifies that <see cref="AuthSqliteConnectionFactory.OpenConnectionAsync"/> opens
    /// the auth database in WAL journal mode so concurrent readers and writers degrade
    /// gracefully instead of surfacing <c>SQLITE_BUSY</c> on the request path.
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    [Fact]
    public async Task OpenConnectionAsync_EnablesWalJournalModeAndBusyTimeout()
    {
        string databasePath = CreateTempDatabasePath();
        await using ServiceProvider services = BuildAuthServices(databasePath);
        AuthSqliteConnectionFactory factory = services.GetRequiredService<AuthSqliteConnectionFactory>();

        await using SqliteConnection connection = await factory.OpenConnectionAsync(CancellationToken.None);

        await using SqliteCommand journalModeCommand = connection.CreateCommand();
        journalModeCommand.CommandText = "PRAGMA journal_mode;";
        string? journalMode = (string?)await journalModeCommand.ExecuteScalarAsync(CancellationToken.None);

        await using SqliteCommand busyTimeoutCommand = connection.CreateCommand();
        busyTimeoutCommand.CommandText = "PRAGMA busy_timeout;";
        long busyTimeout = (long)(await busyTimeoutCommand.ExecuteScalarAsync(CancellationToken.None) ?? 0L);

        Assert.Equal("wal", journalMode, ignoreCase: true);
        Assert.True(busyTimeout > 0, $"Expected a non-zero busy_timeout but found {busyTimeout}.");
    }

    private static ServiceProvider BuildAuthServices(string databasePath)
    {
        IConfigurationRoot configuration = new ConfigurationBuilder()
            .AddInMemoryCollection(
                new Dictionary<string, string?>
                {
                    ["MxGateway:Authentication:SqlitePath"] = databasePath
                })
            .Build();

        ServiceCollection services = new();
        services.AddSingleton<IConfiguration>(configuration);
        services.AddGatewayConfiguration(configuration);
        services.AddSqliteAuthStore(configuration);

        return services.BuildServiceProvider(validateScopes: true);
    }

    /// <summary>Clears SQLite pools and deletes every temporary directory created by this test.</summary>
    public void Dispose()
    {
        foreach (TempDatabaseDirectory directory in _tempDirectories)
        {
            directory.Dispose();
        }

        _tempDirectories.Clear();
    }

    private string CreateTempDatabasePath()
    {
        TempDatabaseDirectory directory = TempDatabaseDirectory.Create("mxgateway-auth-tests");
        _tempDirectories.Add(directory);

        return directory.DatabasePath();
    }

    private static async Task CreateVersionZeroDatabaseAsync(string databasePath)
    {
        await using SqliteConnection connection = CreateConnection(databasePath);
        await connection.OpenAsync(CancellationToken.None);

        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            CREATE TABLE schema_version (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                version INTEGER NOT NULL,
                applied_utc TEXT NOT NULL
            );

            INSERT INTO schema_version (id, version, applied_utc)
            VALUES (1, 0, $applied_utc);
            """;
        command.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    private static async Task CreateSchemaVersionDatabaseAsync(string databasePath, int version)
    {
        await using SqliteConnection connection = CreateConnection(databasePath);
        await connection.OpenAsync(CancellationToken.None);

        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            CREATE TABLE schema_version (
                id INTEGER PRIMARY KEY CHECK (id = 1),
                version INTEGER NOT NULL,
                applied_utc TEXT NOT NULL
            );

            INSERT INTO schema_version (id, version, applied_utc)
            VALUES (1, $version, $applied_utc);
            """;
        command.Parameters.AddWithValue("$version", version);
        command.Parameters.AddWithValue("$applied_utc", DateTimeOffset.UtcNow.ToString("O"));

        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    private static async Task InsertApiKeyAsync(string databasePath, DateTimeOffset? revokedUtc)
    {
        await using SqliteConnection connection = CreateConnection(databasePath);
        await connection.OpenAsync(CancellationToken.None);

        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            INSERT INTO api_keys (
                key_id,
                key_prefix,
                secret_hash,
                display_name,
                scopes,
                created_utc,
                last_used_utc,
                revoked_utc)
            VALUES (
                $key_id,
                $key_prefix,
                $secret_hash,
                $display_name,
                $scopes,
                $created_utc,
                NULL,
                $revoked_utc);
            """;
        command.Parameters.AddWithValue("$key_id", "test-key");
        command.Parameters.AddWithValue("$key_prefix", "mxgw_test");
        command.Parameters.Add("$secret_hash", SqliteType.Blob).Value = new byte[] { 1, 2, 3, 4 };
        command.Parameters.AddWithValue("$display_name", "Test Key");
        command.Parameters.AddWithValue(
            "$scopes",
            ScopeSerializer.Serialize(new HashSet<string>(StringComparer.Ordinal) { "session:open", "events:read" }));
        command.Parameters.AddWithValue("$created_utc", DateTimeOffset.UtcNow.ToString("O"));
        command.Parameters.AddWithValue("$revoked_utc", revokedUtc?.ToString("O") ?? (object)DBNull.Value);

        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    private static async Task<int> ReadSchemaVersionAsync(string databasePath)
    {
        await using SqliteConnection connection = CreateConnection(databasePath);
        await connection.OpenAsync(CancellationToken.None);

        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";

        object? result = await command.ExecuteScalarAsync(CancellationToken.None);

        return Convert.ToInt32(result, System.Globalization.CultureInfo.InvariantCulture);
    }

    private static async Task<bool> TableExistsAsync(string databasePath, string tableName)
    {
        await using SqliteConnection connection = CreateConnection(databasePath);
        await connection.OpenAsync(CancellationToken.None);

        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = """
            SELECT COUNT(*)
            FROM sqlite_master
            WHERE type = 'table' AND name = $table_name;
            """;
        command.Parameters.AddWithValue("$table_name", tableName);

        long result = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);

        return result == 1;
    }

    private static SqliteConnection CreateConnection(string databasePath)
    {
        SqliteConnectionStringBuilder builder = new()
        {
            DataSource = databasePath,
            Mode = SqliteOpenMode.ReadWriteCreate
        };

        return new SqliteConnection(builder.ToString());
    }
}
