using Microsoft.Data.SqlClient;
using Microsoft.EntityFrameworkCore;

namespace ZB.MOM.WW.ScadaBridge.ConfigurationDatabase.Tests.Migrations;

/// <summary>
/// Per-test-class MSSQL fixture for the Bundle C integration tests (#23 M1).
///
/// Creates a fresh, uniquely-named test database on the running infra/mssql
/// container, applies the EF migrations against it, and drops it on dispose.
/// When MSSQL is not reachable (CI without the container), <see cref="Available"/>
/// is set to false and <see cref="SkipReason"/> describes why — tests pair
/// <c>[SkippableFact]</c> with <c>Skip.IfNot(_fixture.Available, _fixture.SkipReason)</c>
/// so the runner reports them as Skipped (not silently Passed).
/// </summary>
/// <remarks>
/// xunit 2.9.x has no native <c>Assert.Skip</c>/<c>Assert.SkipUnless</c> (those
/// are v3); the project uses the Xunit.SkippableFact package as the canonical
/// equivalent. The fixture attempts connect + create-db + migrate once at
/// construct time. The Connect Timeout=3 in <see cref="DefaultAdminConnectionString"/>
/// makes the fixture fail fast in a no-container environment (under ~5s total)
/// instead of hanging 30s on SqlClient's default. Only connect-failure exceptions
/// (SqlException, plus the InvalidOperationException SqlClient raises from
/// OpenAsync) flip Available to false — every other exception bubbles up so a
/// real bug is not silently swallowed.
/// </remarks>
public sealed class MsSqlMigrationFixture : IDisposable
{
    // Same credentials infra/mssql/setup.sql + docker-compose use. Not a committed
    // production secret — this is a local dev container connection string.
    // Connect Timeout=3 makes the fixture fail fast (~3s) in a no-container
    // environment rather than hanging on SqlClient's default 30s connect timeout.
    private const string DefaultAdminConnectionString =
        "Server=localhost,1433;User Id=sa;Password=ScadaBridge_Dev1#;TrustServerCertificate=true;Encrypt=false;Connect Timeout=3";

    private const string AdminEnvVar = "SCADABRIDGE_MSSQL_TEST_CONN";

    public string DatabaseName { get; }

    public string ConnectionString { get; }

    /// <summary>
    /// True when the MSSQL container was reachable at fixture construction
    /// time AND the per-fixture test database was successfully created. When
    /// false, the integration tests using this fixture must early-return.
    /// </summary>
    public bool Available { get; }

    /// <summary>
    /// Populated when <see cref="Available"/> is false; describes why the
    /// fixture chose to skip (env var unset, connect failed, etc.).
    /// </summary>
    public string SkipReason { get; }

    private readonly string _adminConnectionString;

    public MsSqlMigrationFixture()
    {
        // Short, lowercase guid suffix keeps the database identifier under SQL Server's
        // 128-char limit and safe for raw concatenation (no quoting required).
        DatabaseName = $"ScadaBridgeAuditMigTest_{Guid.NewGuid():N}".Substring(0, 38);

        // Env var lets CI / power users override the admin endpoint; absent
        // defaults to the local docker dev container's sa connection.
        var fromEnv = Environment.GetEnvironmentVariable(AdminEnvVar);
        _adminConnectionString = string.IsNullOrWhiteSpace(fromEnv)
            ? DefaultAdminConnectionString
            : fromEnv;

        // Step 1: open the admin connection. This is the only step that may
        // legitimately fail when MSSQL is absent; SqlException + the rare
        // InvalidOperationException from OpenAsync are the connect-failure
        // surfaces we tolerate. Everything else (CREATE DATABASE, MigrateAsync)
        // is treated as a hard fixture failure once we *have* a connection.
        try
        {
            using var connection = new SqlConnection(_adminConnectionString);
            try
            {
                connection.Open();
            }
            catch (SqlException ex)
            {
                ConnectionString = string.Empty;
                Available = false;
                SkipReason = $"MSSQL unavailable (connect failed: SqlException {ex.Number}: {ex.Message})";
                return;
            }
            catch (InvalidOperationException ex)
            {
                ConnectionString = string.Empty;
                Available = false;
                SkipReason = $"MSSQL unavailable (OpenAsync threw: {ex.Message})";
                return;
            }

            using (var createCmd = connection.CreateCommand())
            {
                createCmd.CommandText = $"CREATE DATABASE [{DatabaseName}];";
                createCmd.ExecuteNonQuery();
            }

            ConnectionString = BuildPerDbConnectionString(_adminConnectionString, DatabaseName);

            // Apply the EF migrations once at fixture construction so each test
            // can read from a fully-migrated database without per-test setup.
            // Failures here are real bugs — let them bubble.
            ApplyMigrationsCore(ConnectionString, CancellationToken.None).GetAwaiter().GetResult();

            Available = true;
            SkipReason = string.Empty;
        }
        catch
        {
            // Best-effort cleanup if we created the database but failed before
            // setting Available — otherwise Dispose() would skip the drop.
            TryDropOrphanDatabase();
            throw;
        }
    }

    private void TryDropOrphanDatabase()
    {
        if (string.IsNullOrEmpty(ConnectionString))
        {
            return;
        }

        try
        {
            SqlConnection.ClearAllPools();
            using var connection = new SqlConnection(_adminConnectionString);
            connection.Open();
            using var cmd = connection.CreateCommand();
            cmd.CommandText =
                $"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
                $"BEGIN " +
                $"  ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
                $"  DROP DATABASE [{DatabaseName}]; " +
                $"END";
            cmd.ExecuteNonQuery();
        }
        catch
        {
            // Best-effort — orphan databases carry a random guid suffix.
        }
    }

    /// <summary>
    /// Applies the EF migrations to the per-fixture test database via a freshly
    /// constructed <see cref="ScadaBridgeDbContext"/> pointed at it. Uses the
    /// schema-only single-argument constructor — the AuditLog migration does
    /// not write secret-bearing columns at apply time. Called once from the
    /// constructor; tests do not invoke this directly.
    /// </summary>
    private static async Task ApplyMigrationsCore(string connectionString, CancellationToken cancellationToken)
    {
        var options = new DbContextOptionsBuilder<ScadaBridgeDbContext>()
            .UseSqlServer(connectionString)
            .Options;

        await using var context = new ScadaBridgeDbContext(options);
        await context.Database.MigrateAsync(cancellationToken);
    }

    /// <summary>
    /// Convenience for opening a fresh <see cref="SqlConnection"/> to the test
    /// database. Caller is responsible for disposal.
    /// </summary>
    public SqlConnection OpenConnection()
    {
        ThrowIfUnavailable();

        var connection = new SqlConnection(ConnectionString);
        connection.Open();
        return connection;
    }

    public void Dispose()
    {
        if (!Available)
        {
            return;
        }

        // Best-effort drop — never let a teardown failure pollute later runs.
        // SINGLE_USER WITH ROLLBACK IMMEDIATE detaches lingering pooled connections
        // so the DROP DATABASE doesn't fail with "database is in use".
        try
        {
            // Connection-pool cleanup is necessary because EF's MigrateAsync leaves
            // pooled connections behind; SqlConnection.ClearAllPools() forces them
            // closed so the SINGLE_USER + DROP sequence below can complete.
            SqlConnection.ClearAllPools();

            using var connection = new SqlConnection(_adminConnectionString);
            connection.Open();

            using var cmd = connection.CreateCommand();
            cmd.CommandText =
                $"IF DB_ID(N'{DatabaseName}') IS NOT NULL " +
                $"BEGIN " +
                $"  ALTER DATABASE [{DatabaseName}] SET SINGLE_USER WITH ROLLBACK IMMEDIATE; " +
                $"  DROP DATABASE [{DatabaseName}]; " +
                $"END";
            cmd.ExecuteNonQuery();
        }
        catch
        {
            // Swallow — the database name carries a random guid suffix so a
            // stranded test database does not collide with future runs.
        }
    }

    /// <summary>
    /// Throws an <see cref="InvalidOperationException"/> when invoked on an
    /// unavailable fixture; tests should branch on <see cref="Available"/>
    /// before reaching this code path.
    /// </summary>
    private void ThrowIfUnavailable()
    {
        if (!Available)
        {
            throw new InvalidOperationException(
                $"MsSqlMigrationFixture is not Available: {SkipReason}");
        }
    }

    private static string BuildPerDbConnectionString(string adminConnectionString, string databaseName)
    {
        var builder = new SqlConnectionStringBuilder(adminConnectionString)
        {
            InitialCatalog = databaseName,
        };
        return builder.ConnectionString;
    }
}
