using Microsoft.Data.Sqlite;
using ZB.MOM.WW.Auth.ApiKeys.Sqlite;

namespace ZB.MOM.WW.Auth.ApiKeys.Tests;

public sealed class SqliteMigratorTests : IDisposable
{
    private readonly string _dbPath =
        Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".db");

    private AuthSqliteConnectionFactory Factory => new(_dbPath);

    [Fact]
    public async Task MigrateAsync_CreatesAllThreeTables()
    {
        var migrator = new SqliteAuthStoreMigrator(Factory);

        await migrator.MigrateAsync(CancellationToken.None);

        Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
        Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeyAuditTable));
        Assert.True(await TableExistsAsync(SqliteAuthSchema.SchemaVersionTable));
    }

    [Fact]
    public async Task MigrateAsync_RunTwice_IsIdempotentAndRecordsCurrentVersion()
    {
        var migrator = new SqliteAuthStoreMigrator(Factory);

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

        Assert.Equal(SqliteAuthSchema.CurrentVersion, await ReadVersionAsync());
        Assert.Equal(1, await CountSchemaVersionRowsAsync());
    }

    [Fact]
    public void CurrentVersion_Is3_ForExpiresUtcColumn() =>
        // v3 adds the nullable expires_utc column (archreview G-2). The value must stay >= 2 so the
        // migrator does not refuse a donor MxAccessGateway gateway-auth.db (stamped 2) on first boot:
        // 2 <= 3, so such a DB is accepted and upgraded in place. Locking this invariant.
        Assert.Equal(3, SqliteAuthSchema.CurrentVersion);

    [Fact]
    public async Task MigrateAsync_AgainstExistingVersion2Db_DoesNotThrow_AndUpgradesTo3()
    {
        // The deployed-gateway scenario: a database already provisioned at the donor's version 2.
        // Migrating must accept it (2 <= 3), add the expires_utc column, and re-stamp to 3 — no key
        // is invalidated, the table survives.
        var migrator = new SqliteAuthStoreMigrator(Factory);
        await migrator.MigrateAsync(CancellationToken.None);
        await SetVersionAsync(2);

        await migrator.MigrateAsync(CancellationToken.None); // must not throw

        Assert.Equal(3, await ReadVersionAsync());
        Assert.True(await TableExistsAsync(SqliteAuthSchema.ApiKeysTable));
        Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
    }

    [Fact]
    public async Task MigrateAsync_PreV3ApiKeysTableWithoutExpiresUtc_AddsColumn_AndPreservesRows()
    {
        // Simulate a genuine pre-expiry (v2) database: an api_keys table WITHOUT expires_utc, holding
        // an existing key. The forward migration must add the column (existing row → NULL) without
        // dropping the row.
        await CreateLegacyV2SchemaWithKeyAsync("legacy-key");

        Assert.False(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));

        await new SqliteAuthStoreMigrator(Factory).MigrateAsync(CancellationToken.None);

        Assert.True(await ColumnExistsAsync(SqliteAuthSchema.ApiKeysTable, SqliteAuthSchema.ExpiresUtcColumn));
        Assert.Equal(3, await ReadVersionAsync());

        // The pre-existing key survives and its new expires_utc is NULL (never-expires).
        var read = new SqliteApiKeyStore(Factory);
        var found = await read.FindByKeyIdAsync("legacy-key", CancellationToken.None);
        Assert.NotNull(found);
        Assert.Null(found!.ExpiresUtc);
    }

    private async Task CreateLegacyV2SchemaWithKeyAsync(string keyId)
    {
        await using SqliteConnection connection =
            await Factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = $"""
            CREATE TABLE api_keys (
                key_id TEXT PRIMARY KEY,
                key_prefix TEXT NOT NULL,
                secret_hash BLOB NOT NULL,
                display_name TEXT NOT NULL,
                scopes TEXT NOT NULL,
                constraints TEXT NULL,
                created_utc TEXT NOT NULL,
                last_used_utc TEXT NULL,
                revoked_utc TEXT NULL
            );
            {SqliteAuthSchema.CreateSchemaVersionTable}
            INSERT INTO schema_version (id, version, applied_utc) VALUES (1, 2, '2026-01-01T00:00:00.0000000+00:00');
            INSERT INTO api_keys (key_id, key_prefix, secret_hash, display_name, scopes, created_utc)
            VALUES ($key_id, 'mxgw', X'01020304', 'Legacy', 'read', '2026-01-01T00:00:00.0000000+00:00');
            """;
        command.Parameters.AddWithValue("$key_id", keyId);
        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    private async Task<bool> ColumnExistsAsync(string table, string column)
    {
        await using SqliteConnection connection =
            await Factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = $"PRAGMA table_info({table});";
        await using SqliteDataReader reader = await command.ExecuteReaderAsync(CancellationToken.None);
        while (await reader.ReadAsync(CancellationToken.None))
        {
            if (string.Equals(reader.GetString(1), column, StringComparison.OrdinalIgnoreCase))
            {
                return true;
            }
        }

        return false;
    }

    [Fact]
    public async Task MigrateAsync_FutureSchemaVersion_Throws()
    {
        var migrator = new SqliteAuthStoreMigrator(Factory);
        await migrator.MigrateAsync(CancellationToken.None);

        await SetVersionAsync(99);

        await Assert.ThrowsAsync<AuthStoreMigrationException>(
            () => migrator.MigrateAsync(CancellationToken.None));
    }

    private async Task<bool> TableExistsAsync(string tableName)
    {
        await using SqliteConnection connection =
            await Factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText =
            "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = $name;";
        command.Parameters.AddWithValue("$name", tableName);
        long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
        return count == 1;
    }

    private async Task<int> ReadVersionAsync()
    {
        await using SqliteConnection connection =
            await Factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT version FROM schema_version WHERE id = 1;";
        object? value = await command.ExecuteScalarAsync(CancellationToken.None);
        return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture);
    }

    private async Task<int> CountSchemaVersionRowsAsync()
    {
        await using SqliteConnection connection =
            await Factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "SELECT COUNT(*) FROM schema_version;";
        long count = (long)(await command.ExecuteScalarAsync(CancellationToken.None) ?? 0L);
        return (int)count;
    }

    private async Task SetVersionAsync(int version)
    {
        await using SqliteConnection connection =
            await Factory.OpenConnectionAsync(CancellationToken.None);
        await using SqliteCommand command = connection.CreateCommand();
        command.CommandText = "UPDATE schema_version SET version = $version WHERE id = 1;";
        command.Parameters.AddWithValue("$version", version);
        await command.ExecuteNonQueryAsync(CancellationToken.None);
    }

    public void Dispose()
    {
        SqliteConnection.ClearAllPools();
        TryDelete(_dbPath);
        TryDelete(_dbPath + "-wal");
        TryDelete(_dbPath + "-shm");
    }

    private static void TryDelete(string path)
    {
        try
        {
            if (File.Exists(path))
            {
                File.Delete(path);
            }
        }
        catch (IOException)
        {
            // Best-effort cleanup of the per-test temp database.
        }
    }
}
