using System.Globalization;
using Microsoft.Data.SqlClient;
using Xunit;

namespace ZB.MOM.WW.OtOpcUa.Driver.Sql.IntegrationTests;

/// <summary>
///     A real, seeded <b>SQL Server</b> database standing in for the source the <c>Sql</c> driver polls —
///     the live counterpart of the offline <c>SqlitePollFixture</c>, and the only place the shipped
///     <c>Microsoft.Data.SqlClient</c> path, the real <c>INFORMATION_SCHEMA</c> catalog SQL, and genuine
///     T-SQL column types are exercised at all.
///     <para><b>Env-gated, and the gate is checked before any socket is touched.</b> This fixture opens
///     nothing unless <see cref="ConnectionStringEnvVar"/> — or <see cref="EndpointEnvVar"/> plus a
///     password — is set; absent, it records <see cref="SkipReason"/> and every test calls
///     <c>Assert.Skip</c>. That is what keeps <c>dotnet test</c> genuinely green (and instant) on the
///     macOS dev box this is usually run from, which is the same contract every other
///     <c>*.IntegrationTests</c> fixture in this tree honours.</para>
///     <para><b>Safety against the shared central server (design §9).</b> The always-on SQL Server on the
///     docker host hosts <c>ConfigDb</c> for the whole dev rig, so this fixture is deliberately
///     conservative:</para>
///     <list type="bullet">
///         <item>It uses <b>its own database</b>, <see cref="FixtureDatabase"/> — the supplied connection
///         string's catalog is used only to reach <c>master</c> and is otherwise <b>ignored</b>, so a
///         mis-set env var cannot aim the seed at somebody else's schema.</item>
///         <item>A supplied catalog literally named <c>ConfigDb</c> is rejected <b>loudly, at
///         construction</b>, rather than quietly retargeted — it is the signature of a copied-and-pasted
///         connection string, and the operator should know their env var is wrong.</item>
///         <item>It <b>creates the database only if missing</b> and <b>never drops a database</b>, so
///         re-running the suite is safe and an operator-created <c>SqlPollFixture</c> is never destroyed.
///         Only the objects inside its own <see cref="Schema"/> schema are dropped and recreated, which
///         is what makes the seed deterministic across runs.</item>
///     </list>
///     <para><b>The seed is a contract, not scaffolding.</b> The constants below mirror
///     <c>SqlitePollFixture</c>'s shape — a present value, a present row with a <b>NULL</b> cell, an
///     <b>absent</b> key, and a wide-row table whose newest row is deliberately not its first — so the
///     same assertions can be made offline and live, and a divergence between the SQLite and SQL Server
///     paths shows up as a failing assertion rather than as two suites that quietly test different
///     things. <see cref="TypeProbeTable"/> has no offline counterpart: it exists to pin
///     <c>SqlServerDialect.MapColumnType</c> against the type names SQL Server <em>actually</em> reports,
///     which a hand-written mapping table cannot do.</para>
/// </summary>
public sealed class SqlPollServerFixture : IAsyncLifetime
{
    /// <summary>A full ADO.NET connection string for the fixture server. Takes precedence when set.</summary>
    public const string ConnectionStringEnvVar = "Sql__ConnectionStrings__Fixture";

    /// <summary>
    ///     Server endpoint in <c>host,port</c> form (e.g. <c>10.100.0.35,14330</c>) — the lighter-weight
    ///     gate. Requires <see cref="PasswordEnvVar"/>; <see cref="UserEnvVar"/> defaults to <c>sa</c>.
    /// </summary>
    public const string EndpointEnvVar = "SQL_TEST_ENDPOINT";

    /// <summary>SQL login for the <see cref="EndpointEnvVar"/> form. Defaults to <c>sa</c>.</summary>
    public const string UserEnvVar = "SQL_TEST_USER";

    /// <summary>SQL password for the <see cref="EndpointEnvVar"/> form. Required by that form.</summary>
    public const string PasswordEnvVar = "SQL_TEST_PASSWORD";

    /// <summary>The database this fixture creates and seeds. Never <c>ConfigDb</c>, never dropped.</summary>
    public const string FixtureDatabase = "SqlPollFixture";

    /// <summary>
    ///     The schema every seeded object lives in. Deliberately <b>not</b> <c>dbo</c>: it scopes the
    ///     drop-and-recreate to objects this fixture owns, and it makes every authored <c>table</c> a
    ///     two-part name, so the planner's <c>QuoteQualifiedName</c> (<c>[sqlpoll].[TagValues]</c>) is
    ///     exercised against a real server rather than only in a unit test.
    /// </summary>
    public const string Schema = "sqlpoll";

    /// <summary>
    ///     The <c>Application Name</c> the driver's connections carry, so the pooling assertion can count
    ///     exactly this suite's sessions on a server shared with the whole dev rig.
    /// </summary>
    public const string DriverApplicationName = "OtOpcUa.Sql.ITs.driver";

    /// <summary>The <c>Application Name</c> the fixture's own inspection connections carry.</summary>
    private const string FixtureApplicationName = "OtOpcUa.Sql.ITs.fixture";

    /// <summary>Seconds a connect attempt may take before the fixture calls the server unreachable.</summary>
    private const int ProbeConnectTimeoutSeconds = 5;

    // ---- key-value (EAV) source ----

    /// <summary>The key-value table: <c>tag_name nvarchar(128), num_value float NULL, sample_ts datetime2</c>.</summary>
    public const string KeyValueTable = "TagValues";

    /// <summary>The key-value table's key column.</summary>
    public const string KeyColumn = "tag_name";

    /// <summary>The key-value table's value column.</summary>
    public const string ValueColumn = "num_value";

    /// <summary>The source-timestamp column carried by every seeded table.</summary>
    public const string TimestampColumn = "sample_ts";

    /// <summary>A key whose row exists and whose value cell holds <see cref="PresentValue"/>.</summary>
    public const string PresentKey = "Line1.Speed";

    /// <summary>The value seeded for <see cref="PresentKey"/>.</summary>
    public const double PresentValue = 42.0;

    /// <summary>A second present key, so a group can legitimately fold more than one member.</summary>
    public const string SecondPresentKey = "Line1.Pressure";

    /// <summary>The value seeded for <see cref="SecondPresentKey"/>.</summary>
    public const double SecondPresentValue = 3.5;

    /// <summary>A key whose row <b>exists</b> but whose value cell is <c>NULL</c>.</summary>
    public const string NullValueKey = "Line1.Temp";

    /// <summary>A key with <b>no row at all</b> — a different outcome from <see cref="NullValueKey"/>.</summary>
    public const string AbsentKey = "Line1.Missing";

    /// <summary>The UTC source timestamp seeded for <see cref="PresentKey"/>.</summary>
    public static readonly DateTime PresentKeyTimestampUtc =
        new(2026, 7, 24, 10, 0, 0, DateTimeKind.Utc);

    // ---- wide-row source ----

    /// <summary>The wide-row table: <c>station_id int, oven_temp float NULL, pressure float NULL, sample_ts datetime2</c>.</summary>
    public const string WideRowTable = "LatestStatus";

    /// <summary>The wide-row table's row-selector column.</summary>
    public const string WideRowSelectorColumn = "station_id";

    /// <summary>A station whose row exists, with both value columns populated.</summary>
    public const string PresentStation = "7";

    /// <summary><see cref="PresentStation"/>'s <c>oven_temp</c>.</summary>
    public const double PresentStationOvenTemp = 180.5;

    /// <summary><see cref="PresentStation"/>'s <c>pressure</c>.</summary>
    public const double PresentStationPressure = 1.2;

    /// <summary>
    ///     The station holding the <b>newest</b> <c>sample_ts</c> — what a <c>topByTimestamp</c> selector
    ///     must resolve to. Deliberately not the first-inserted row, so a plan that loses its
    ///     <c>ORDER BY … DESC</c> or its <c>SELECT TOP 1</c> picks the wrong one and the test says so.
    /// </summary>
    public const string NewestStation = "8";

    /// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — what a <c>topByTimestamp</c> plan yields.</summary>
    public const double NewestStationOvenTemp = 210.25;

    /// <summary>A station whose row exists but whose <c>oven_temp</c> cell is <c>NULL</c>.</summary>
    public const string NullOvenTempStation = "9";

    /// <summary>A station with no row at all.</summary>
    public const string AbsentStation = "404";

    /// <summary>A view over <see cref="WideRowTable"/> — the <c>TABLE_TYPE = 'VIEW'</c> catalog row.</summary>
    public const string WideRowView = "LatestStatusView";

    // ---- T-SQL type-coverage source ----

    /// <summary>
    ///     One wide row carrying one column per T-SQL family the dialect claims to map. Read with no
    ///     authored <c>type</c>, each column's driver type is inferred from what
    ///     <c>SqlDataReader.GetDataTypeName</c> reports — which is the only way to find out whether
    ///     <c>SqlServerDialect.MapColumnType</c>'s table matches reality rather than matching itself.
    /// </summary>
    public const string TypeProbeTable = "TypeProbe";

    /// <summary>The single-row selector column of <see cref="TypeProbeTable"/>.</summary>
    public const string TypeProbeSelectorColumn = "probe_id";

    /// <summary>The seeded row's <see cref="TypeProbeSelectorColumn"/> value.</summary>
    public const string TypeProbeRow = "1";

    /// <summary>Seeded <c>bit</c> value.</summary>
    public const bool ProbeBit = true;

    /// <summary>Seeded <c>int</c> value — <c>int.MaxValue</c>, so a narrowing coercion overflows loudly.</summary>
    public const int ProbeInt = int.MaxValue;

    /// <summary>Seeded <c>bigint</c> value — beyond exact <c>double</c> range, so an Int64→Float64 slip shows.</summary>
    public const long ProbeBigInt = 9007199254740993L;

    /// <summary>Seeded <c>real</c> value; exactly representable, so equality is a fair assertion.</summary>
    public const float ProbeReal = 1.5f;

    /// <summary>Seeded <c>float</c> value; exactly representable.</summary>
    public const double ProbeFloat = 3.25d;

    /// <summary>Seeded <c>decimal(18,4)</c> value — the documented Float64 precision collapse.</summary>
    public const decimal ProbeDecimal = 123.4567m;

    /// <summary>Seeded <c>nvarchar(64)</c> value.</summary>
    public const string ProbeNVarChar = "hello";

    /// <summary>Seeded <c>datetime2(3)</c> value, read back as UTC.</summary>
    public static readonly DateTime ProbeDateTimeUtc =
        new(2026, 7, 24, 10, 0, 3, DateTimeKind.Utc);

    /// <summary>
    ///     Why the suite is skipping, or <see langword="null"/> when the fixture is seeded and usable.
    ///     Tests read this and call <c>Assert.Skip</c> — the house idiom
    ///     (<c>Snap7ServerFixture</c> / <c>ModbusSimulatorFixture</c>).
    /// </summary>
    public string? SkipReason { get; private set; }

    /// <summary>
    ///     The connection string to hand the code under test: the seeded <see cref="FixtureDatabase"/>,
    ///     tagged with <see cref="DriverApplicationName"/>. Empty while <see cref="SkipReason"/> is set.
    /// </summary>
    public string ConnectionString { get; private set; } = string.Empty;

    /// <summary>The server the fixture resolved, for skip/diagnostic messages. Never carries credentials.</summary>
    public string Server { get; private set; } = string.Empty;

    /// <summary>Connection string the fixture's own inspection connections use. Empty while skipping.</summary>
    private string _inspectionConnectionString = string.Empty;

    /// <summary>
    ///     Resolves the gate, creates <see cref="FixtureDatabase"/> if missing, and (re)seeds this
    ///     fixture's schema.
    ///     <para>The two failure modes are handled deliberately differently: <b>failing to connect</b> is
    ///     the offline case and becomes a <see cref="SkipReason"/>, while <b>failing after the connection
    ///     opened</b> (DDL, seeding) is a real fault against a reachable server and is allowed to throw —
    ///     swallowing that would leave the suite silently testing an unseeded database.</para>
    /// </summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask InitializeAsync()
    {
        if (ResolveConnectionString() is not { } supplied)
        {
            SkipReason =
                $"Set {ConnectionStringEnvVar} (a full connection string) — or {EndpointEnvVar} " +
                $"(host,port) plus {PasswordEnvVar} (and optionally {UserEnvVar}, default 'sa') — to run " +
                "the Sql driver's live SQL Server suite. No connection is attempted without it.";
            return;
        }

        var builder = new SqlConnectionStringBuilder(supplied);
        GuardAgainstConfigDb(builder);
        if (!builder.ContainsKey("Connect Timeout")) builder.ConnectTimeout = ProbeConnectTimeoutSeconds;
        Server = builder.DataSource;

        // The supplied catalog is used ONLY to reach master; the seed always lands in FixtureDatabase.
        var master = new SqlConnectionStringBuilder(builder.ConnectionString)
        {
            InitialCatalog = "master",
            ApplicationName = FixtureApplicationName,
        }.ConnectionString;

        SqlConnection connection;
        try
        {
            connection = new SqlConnection(master);
            await connection.OpenAsync().ConfigureAwait(false);
        }
        catch (Exception ex)
        {
            SkipReason =
                $"SQL Server at '{Server}' was not reachable within {ProbeConnectTimeoutSeconds}s " +
                $"({ex.GetType().Name}: {ex.Message}). Check the endpoint and credentials in " +
                $"{ConnectionStringEnvVar}/{EndpointEnvVar}, or leave them unset to skip this suite.";
            return;
        }

        await using (connection.ConfigureAwait(false))
        {
            // Create-if-missing. This fixture never drops a database — an existing SqlPollFixture, whoever
            // made it, keeps everything outside this fixture's own schema.
            await ExecuteAsync(
                connection,
                $"IF DB_ID(N'{FixtureDatabase}') IS NULL CREATE DATABASE [{FixtureDatabase}];")
                .ConfigureAwait(false);
        }

        ConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
        {
            InitialCatalog = FixtureDatabase,
            ApplicationName = DriverApplicationName,
        }.ConnectionString;

        _inspectionConnectionString = new SqlConnectionStringBuilder(builder.ConnectionString)
        {
            InitialCatalog = FixtureDatabase,
            ApplicationName = FixtureApplicationName,
        }.ConnectionString;

        await SeedAsync().ConfigureAwait(false);
    }

    /// <summary>Holds no long-lived connection, so there is nothing to tear down.</summary>
    /// <returns>A completed task.</returns>
    public ValueTask DisposeAsync() => ValueTask.CompletedTask;

    /// <summary>
    ///     Opens a fresh connection to the seeded database for direct arrangement or inspection, tagged
    ///     with the fixture's own application name so it is never counted by the pooling assertion.
    /// </summary>
    /// <returns>An open connection the caller owns and must dispose.</returns>
    public async Task<SqlConnection> OpenInspectionConnectionAsync()
    {
        var connection = new SqlConnection(_inspectionConnectionString);
        await connection.OpenAsync().ConfigureAwait(false);
        return connection;
    }

    /// <summary>Two-part name of a seeded object, in the form an authored tag's <c>table</c> carries.</summary>
    /// <param name="table">The bare table or view name.</param>
    /// <returns>The <c>schema.table</c> name.</returns>
    public static string Qualified(string table) => $"{Schema}.{table}";

    /// <summary>
    ///     Reads the connection-string gate. Returns <see langword="null"/> — touching no socket — when
    ///     neither form is configured.
    /// </summary>
    private static string? ResolveConnectionString()
    {
        if (Environment.GetEnvironmentVariable(ConnectionStringEnvVar) is { Length: > 0 } full)
            return full;

        if (Environment.GetEnvironmentVariable(EndpointEnvVar) is not { Length: > 0 } endpoint)
            return null;

        // A password is required rather than defaulted: guessing one would turn a misconfiguration into a
        // failed login against a shared server, which is how accounts get locked out.
        if (Environment.GetEnvironmentVariable(PasswordEnvVar) is not { Length: > 0 } password)
            return null;

        var user = Environment.GetEnvironmentVariable(UserEnvVar) is { Length: > 0 } named ? named : "sa";
        return new SqlConnectionStringBuilder
        {
            DataSource = endpoint,
            InitialCatalog = FixtureDatabase,
            UserID = user,
            Password = password,
            TrustServerCertificate = true,
            Encrypt = false,
        }.ConnectionString;
    }

    /// <summary>
    ///     Refuses, loudly and before any I/O, a connection string aimed at the rig's shared
    ///     <c>ConfigDb</c>. The catalog is ignored either way (everything lands in
    ///     <see cref="FixtureDatabase"/>), so this exists purely to tell an operator their env var is
    ///     wrong rather than to prevent damage that could otherwise occur.
    /// </summary>
    /// <exception cref="InvalidOperationException">The supplied catalog is named <c>ConfigDb</c>.</exception>
    private static void GuardAgainstConfigDb(SqlConnectionStringBuilder builder)
    {
        if (!string.Equals(builder.InitialCatalog, "ConfigDb", StringComparison.OrdinalIgnoreCase)) return;

        throw new InvalidOperationException(
            $"{ConnectionStringEnvVar} names the database 'ConfigDb'. That is the dev rig's shared " +
            $"configuration database; this fixture seeds its own '{FixtureDatabase}' database and must " +
            "never be pointed at ConfigDb. Point the connection string at 'master' (or omit the catalog).");
    }

    /// <summary>
    ///     Drops and recreates this fixture's schema and every object in it, then inserts the seed rows.
    ///     <para>Drop-and-recreate rather than merge-or-insert because the seed is an assertion contract:
    ///     a leftover row from an older revision of this file would make a passing test meaningless. The
    ///     blast radius is bounded to the <see cref="Schema"/> schema, which nothing else creates.</para>
    /// </summary>
    private async Task SeedAsync()
    {
        var connection = await OpenInspectionConnectionAsync().ConfigureAwait(false);
        await using (connection.ConfigureAwait(false))
        {
            // Views first: a view over a dropped table blocks nothing, but dropping the table under a live
            // view leaves the catalog reporting a column set that no longer resolves.
            foreach (var statement in SeedStatements())
                await ExecuteAsync(connection, statement).ConfigureAwait(false);
        }
    }

    /// <summary>
    ///     The seed, one batch per statement. <c>CREATE SCHEMA</c> and <c>CREATE VIEW</c> must each be the
    ///     first statement of their batch in T-SQL, which is why this is a list rather than one script.
    ///     <para>Every numeric literal is composed under <see cref="CultureInfo.InvariantCulture"/>: plain
    ///     interpolation would emit <c>180,5</c> on a comma-decimal machine, and T-SQL would read that as
    ///     two column values rather than as one wrong number — a seed failure with a baffling message.</para>
    /// </summary>
    private static IEnumerable<string> SeedStatements()
    {
        yield return $"DROP VIEW IF EXISTS [{Schema}].[{WideRowView}];";
        yield return $"DROP TABLE IF EXISTS [{Schema}].[{KeyValueTable}];";
        yield return $"DROP TABLE IF EXISTS [{Schema}].[{WideRowTable}];";
        yield return $"DROP TABLE IF EXISTS [{Schema}].[{TypeProbeTable}];";
        yield return $"IF SCHEMA_ID(N'{Schema}') IS NULL EXEC(N'CREATE SCHEMA [{Schema}]');";

        yield return $"""
            CREATE TABLE [{Schema}].[{KeyValueTable}] (
                [{KeyColumn}]       nvarchar(128) NOT NULL,
                [{ValueColumn}]     float         NULL,
                [{TimestampColumn}] datetime2(3)  NOT NULL
            );
            """;
        yield return string.Create(CultureInfo.InvariantCulture, $"""
            INSERT INTO [{Schema}].[{KeyValueTable}] ([{KeyColumn}], [{ValueColumn}], [{TimestampColumn}])
            VALUES (N'{PresentKey}',       {PresentValue},       '2026-07-24T10:00:00'),
                   (N'{NullValueKey}',     NULL,                 '2026-07-24T10:00:01'),
                   (N'{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02');
            """);

        yield return $"""
            CREATE TABLE [{Schema}].[{WideRowTable}] (
                [{WideRowSelectorColumn}] int          NOT NULL,
                [oven_temp]               float        NULL,
                [pressure]                float        NULL,
                [{TimestampColumn}]       datetime2(3) NOT NULL
            );
            """;
        // NewestStation is inserted LAST but is also the newest timestamp; PresentStation is first. A
        // topByTimestamp plan that lost its ORDER BY would still pick a plausible-looking row, so the two
        // orderings are kept deliberately different.
        yield return string.Create(CultureInfo.InvariantCulture, $"""
            INSERT INTO [{Schema}].[{WideRowTable}]
                ([{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}])
            VALUES ({PresentStation},      {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00'),
                   ({NullOvenTempStation}, NULL,                     1.6,                      '2026-07-24T10:00:01'),
                   ({NewestStation},       {NewestStationOvenTemp},  1.4,                      '2026-07-24T10:00:02');
            """);

        yield return $"""
            CREATE VIEW [{Schema}].[{WideRowView}] AS
                SELECT [{WideRowSelectorColumn}], [oven_temp], [pressure], [{TimestampColumn}]
                FROM [{Schema}].[{WideRowTable}];
            """;

        yield return $"""
            CREATE TABLE [{Schema}].[{TypeProbeTable}] (
                [{TypeProbeSelectorColumn}] int            NOT NULL,
                [bit_col]                   bit            NULL,
                [int_col]                   int            NULL,
                [bigint_col]                bigint         NULL,
                [real_col]                  real           NULL,
                [float_col]                 float          NULL,
                [decimal_col]               decimal(18,4)  NULL,
                [nvarchar_col]              nvarchar(64)   NULL,
                [datetime2_col]             datetime2(3)   NULL,
                [{TimestampColumn}]         datetime2(3)   NOT NULL
            );
            """;
        yield return string.Create(CultureInfo.InvariantCulture, $"""
            INSERT INTO [{Schema}].[{TypeProbeTable}]
                ([{TypeProbeSelectorColumn}], [bit_col], [int_col], [bigint_col], [real_col], [float_col],
                 [decimal_col], [nvarchar_col], [datetime2_col], [{TimestampColumn}])
            VALUES ({TypeProbeRow}, 1, {ProbeInt}, {ProbeBigInt}, {ProbeReal}, {ProbeFloat},
                    {ProbeDecimal}, N'{ProbeNVarChar}', '2026-07-24T10:00:03', '2026-07-24T10:00:03');
            """);
    }

    /// <summary>Runs one non-query batch.</summary>
    private static async Task ExecuteAsync(SqlConnection connection, string sql)
    {
        var command = connection.CreateCommand();
        await using (command.ConfigureAwait(false))
        {
            command.CommandText = sql;
            await command.ExecuteNonQueryAsync().ConfigureAwait(false);
        }
    }
}

/// <summary>
///     Collection definition so the seed runs once per test session rather than once per test class —
///     the same shape as <c>Snap7ServerCollection</c>.
/// </summary>
[CollectionDefinition(Name)]
public sealed class SqlPollServerCollection : ICollectionFixture<SqlPollServerFixture>
{
    /// <summary>The collection name test classes reference.</summary>
    public const string Name = "SqlPollServer";
}
