using System.Data.Common;
using System.Globalization;
using Microsoft.Data.Sqlite;

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

/// <summary>
///     A real, seeded SQLite database standing in for the SQL Server the driver polls — the offline substrate
///     for every reader test. It exists so the poll path is exercised against a genuine
///     <see cref="DbConnection"/>: real parameter binding, real provider type coercion, real
///     <see cref="DBNull"/>, real "no such row".
///     <para><b>Backed by a temporary FILE, not <c>:memory:</c>.</b> The reader opens a <em>fresh</em>
///     connection per poll (<c>Factory.CreateConnection()</c> → set <see cref="ConnectionString"/> →
///     <c>OpenAsync</c>) and closes it afterwards. A plain in-memory SQLite database is destroyed the moment
///     its last connection closes, so poll #2 would silently find an empty schema — the seed would evaporate
///     between polls and every test would fail for a reason that has nothing to do with the code under test.
///     The alternative (a shared-cache in-memory database pinned by a keep-alive connection) works but adds an
///     invisible lifetime invariant: every consumer would have to keep the fixture alive for the database to
///     exist at all. A temp file has neither problem — it survives arbitrary open/close cycles, needs no
///     keep-alive, and its connection string is an ordinary path the code under test takes verbatim.</para>
///     <para><b>The fixture is a contract, not scaffolding.</b> The seeded shapes below are what reader tests
///     assert against, so the constants are public and the seed deliberately covers the three cases that
///     behave differently: a present value, a present row whose value cell is <b>NULL</b>, and a key that is
///     <b>absent</b> entirely. Change a constant and you are changing what the reader is proven to do.</para>
/// </summary>
public sealed class SqlitePollFixture : IDisposable
{
    // ---- key-value (EAV) source ----

    /// <summary>The key-value table: <c>TagValues(tag_name TEXT, num_value REAL, sample_ts TEXT)</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 both seeded tables.</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> — the case that must not be
    ///     confused with <see cref="AbsentKey"/>: the row was read, the value is simply not there.
    /// </summary>
    public const string NullValueKey = "Line1.Temp";

    /// <summary>
    ///     A key with <b>no row at all</b>. Distinct from <see cref="NullValueKey"/> on purpose: a missing row
    ///     means the poll returned nothing for that tag, which is a different quality outcome from a NULL cell.
    /// </summary>
    public const string AbsentKey = "Line1.Missing";

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

    /// <summary>
    ///     The wide-row table:
    ///     <c>LatestStatus(station_id INTEGER, oven_temp REAL, pressure REAL, sample_ts TEXT)</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> row selector
    ///     must resolve to. Deliberately not the first-inserted row, so a plan that forgets the
    ///     <c>ORDER BY … DESC</c> (or the row limit) picks the wrong one and the test says so.
    /// </summary>
    public const string NewestStation = "8";

    /// <summary><see cref="NewestStation"/>'s <c>oven_temp</c> — the value 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";

    private readonly string _databasePath;

    /// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
    public SqlitePollFixture()
    {
        _databasePath = Path.Combine(
            Path.GetTempPath(), $"otopcua-sql-poll-{Guid.NewGuid():N}.db");
        ConnectionString = new SqliteConnectionStringBuilder
        {
            DataSource = _databasePath,
        }.ToString();

        Connection = new SqliteConnection(ConnectionString);
        Connection.Open();
        Seed(Connection);
    }

    /// <summary>
    ///     The connection string to hand the code under test. An ordinary file path — safe to open and close
    ///     any number of times, from any number of connections, in any order.
    /// </summary>
    public string ConnectionString { get; }

    /// <summary>
    ///     The provider factory to hand the code under test, matching the reader's
    ///     <c>(DbProviderFactory factory, string connectionString, …)</c> seam.
    ///     <para>This is the <em>real</em> <see cref="SqliteFactory"/>, not a shim that hands back a
    ///     pre-opened connection: because the database is a file, the reader's genuine
    ///     create → open → query → close cycle works unmodified, so what the tests exercise is the production
    ///     connection lifecycle rather than a test-only shortcut around it.</para>
    /// </summary>
    public DbProviderFactory Factory => SqliteFactory.Instance;

    /// <summary>
    ///     A long-lived open connection over the same database, for arranging extra state or inspecting
    ///     results directly. Independent of the connections the code under test opens — closing one has no
    ///     effect on the others, and none of them destroy the data.
    /// </summary>
    public SqliteConnection Connection { get; }

    /// <summary>Opens a brand-new connection, exactly as the reader does on each poll.</summary>
    /// <returns>An open connection the caller owns and must dispose.</returns>
    public SqliteConnection OpenNewConnection()
    {
        var connection = new SqliteConnection(ConnectionString);
        connection.Open();
        return connection;
    }

    /// <summary>Runs one non-query statement against <see cref="Connection"/>, for test-local arrangement.</summary>
    /// <param name="sql">The statement to execute.</param>
    public void Execute(string sql)
    {
        using var command = Connection.CreateCommand();
        command.CommandText = sql;
        command.ExecuteNonQuery();
    }

    /// <summary>Closes the fixture's connection, clears the pool, and deletes the temporary database file.</summary>
    public void Dispose()
    {
        Connection.Close();
        Connection.Dispose();
        // Microsoft.Data.Sqlite pools connections per connection string; without this the file can still be
        // held open and the delete silently fails, leaking a file per test class into the temp directory.
        SqliteConnection.ClearAllPools();
        try
        {
            if (File.Exists(_databasePath)) File.Delete(_databasePath);
        }
        catch (IOException)
        {
            // A leaked temp file must never fail a test run.
        }
    }

    /// <summary>
    ///     Applies the schema and rows described by this type's constants.
    ///     <para>Every column is <b>explicitly declared</b>. SQLite is dynamically typed and would happily
    ///     store a string in an undeclared column, which would make the reader's type-coercion tests prove
    ///     nothing; declared types give <c>pragma_table_info</c> (and therefore
    ///     <see cref="SqliteDialect.MapColumnType"/>) something real to report.</para>
    /// </summary>
    private static void Seed(SqliteConnection connection)
    {
        using var command = connection.CreateCommand();
        command.CommandText = string.Create(CultureInfo.InvariantCulture, $"""
            CREATE TABLE {KeyValueTable} (
                {KeyColumn}       TEXT NOT NULL,
                {ValueColumn}     REAL NULL,
                {TimestampColumn} TEXT NOT NULL
            );
            INSERT INTO {KeyValueTable} ({KeyColumn}, {ValueColumn}, {TimestampColumn}) VALUES
                ('{PresentKey}',       {PresentValue},       '2026-07-24T10:00:00Z'),
                ('{NullValueKey}',     NULL,                 '2026-07-24T10:00:01Z'),
                ('{SecondPresentKey}', {SecondPresentValue}, '2026-07-24T10:00:02Z');

            CREATE TABLE {WideRowTable} (
                {WideRowSelectorColumn} INTEGER NOT NULL,
                oven_temp               REAL NULL,
                pressure                REAL NULL,
                {TimestampColumn}       TEXT NOT NULL
            );
            INSERT INTO {WideRowTable} ({WideRowSelectorColumn}, oven_temp, pressure, {TimestampColumn}) VALUES
                ({PresentStation},       {PresentStationOvenTemp}, {PresentStationPressure}, '2026-07-24T10:00:00Z'),
                ({NullOvenTempStation},  NULL,                     1.6,                      '2026-07-24T10:00:01Z'),
                ({NewestStation},        {NewestStationOvenTemp},  1.4,                      '2026-07-24T10:00:02Z');
            """);
        command.ExecuteNonQuery();
    }
}
