using System.Data;
using System.Globalization;
using Microsoft.Data.SqlClient;
using ZB.MOM.WW.Secrets.Abstractions;

namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;

/// <summary>
/// Creates the shared SQL-Server secret schema and records the applied version. Idempotent: safe to
/// run on every node at every startup, including concurrently. Refuses to run against a database
/// whose recorded version is newer than this build supports.
/// </summary>
/// <param name="connectionFactory">Factory for connections to the shared secret database.</param>
public sealed class SqlServerSecretsStoreMigrator(SecretsSqlServerConnectionFactory connectionFactory)
    : ISecretsStoreMigrator
{
    // Concurrent Serializable DDL against the system catalogs can pick a deadlock victim (error
    // 1205) even though the IF NOT EXISTS guards make the logic itself safe. Every node runs this at
    // every startup, so a virgin database being provisioned by N nodes at once is the NORMAL case,
    // not a rare race — and an unretried victim aborts that node's whole host at startup. Retrying
    // is correct precisely because the migration is idempotent.
    private const int MaxDeadlockRetries = 5;
    private const int SqlDeadlockVictimErrorNumber = 1205;

    /// <inheritdoc />
    /// <exception cref="SecretStoreMigrationException">
    /// The recorded schema version is newer than <see cref="SqlServerSecretsSchema.CurrentVersion"/>.
    /// </exception>
    public async Task MigrateAsync(CancellationToken cancellationToken)
    {
        for (int attempt = 1; ; attempt++)
        {
            try
            {
                await MigrateOnceAsync(cancellationToken).ConfigureAwait(false);
                return;
            }
            catch (SqlException ex)
                when (ex.Number == SqlDeadlockVictimErrorNumber && attempt < MaxDeadlockRetries)
            {
                // Staggered back-off so two victims do not retry in lockstep and deadlock again.
                await Task.Delay(TimeSpan.FromMilliseconds(100 * attempt), cancellationToken)
                    .ConfigureAwait(false);
            }
        }
    }

    private async Task MigrateOnceAsync(CancellationToken cancellationToken)
    {
        string schema = connectionFactory.SchemaName;

        await using SqlConnection connection =
            await connectionFactory.OpenConnectionAsync(cancellationToken).ConfigureAwait(false);

        // Serializable: unlike the single-writer SQLite store, EVERY node in the cluster runs this
        // migration at startup, so concurrent first-boots are the normal case rather than a rare
        // race. The isolation level plus the IF NOT EXISTS guards make a simultaneous provision by
        // two nodes converge on one schema instead of one of them failing with "object exists".
        await using SqlTransaction transaction = (SqlTransaction)
            await connection.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken)
                .ConfigureAwait(false);

        int existingVersion =
            await ReadExistingSchemaVersionAsync(connection, transaction, schema, cancellationToken)
                .ConfigureAwait(false);

        if (existingVersion > SqlServerSecretsSchema.CurrentVersion)
        {
            throw new SecretStoreMigrationException(
                $"Shared secret database schema version {existingVersion} is newer than supported version " +
                $"{SqlServerSecretsSchema.CurrentVersion}. Upgrade this node before it writes to the shared store.");
        }

        await using (SqlCommand ddl = connectionFactory.CreateCommand(
            connection, SqlServerSecretsSchema.CreateSchemaDdl(schema), transaction))
        {
            await ddl.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }

        await using (SqlCommand version = connectionFactory.CreateCommand(
            connection,
            $"""
            MERGE [{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}] AS target
            USING (SELECT 1 AS id, @version AS version, @applied_utc AS applied_utc) AS source
                ON target.id = source.id
            WHEN MATCHED THEN
                UPDATE SET version = source.version, applied_utc = source.applied_utc
            WHEN NOT MATCHED THEN
                INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
            """,
            transaction))
        {
            version.Parameters.AddWithValue("@version", SqlServerSecretsSchema.CurrentVersion);
            version.Parameters.AddWithValue("@applied_utc", DateTimeOffset.UtcNow.ToString("O"));
            await version.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
        }

        await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
    }

    private static async Task<int> ReadExistingSchemaVersionAsync(
        SqlConnection connection,
        SqlTransaction transaction,
        string schema,
        CancellationToken cancellationToken)
    {
        await using SqlCommand command = connection.CreateCommand();
        command.Transaction = transaction;

        // OBJECT_ID returns NULL for an absent table, so this reads 0 on a virgin database without
        // needing the table to exist first.
        command.CommandText = $"""
            IF OBJECT_ID(N'[{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}]', N'U') IS NULL
                SELECT 0;
            ELSE
                SELECT ISNULL((SELECT version FROM [{schema}].[{SqlServerSecretsSchema.SchemaVersionTable}] WHERE id = 1), 0);
            """;

        object? version = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);

        return version is null || version == DBNull.Value
            ? 0
            : Convert.ToInt32(version, CultureInfo.InvariantCulture);
    }
}
