using Microsoft.Data.Sqlite;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

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

/// <summary>
///     Proves the offline substrate the reader tests will stand on: that <see cref="SqlitePollFixture"/>
///     survives the reader's real per-poll connection lifecycle, and that a
///     <see cref="SqlGroupPlanner"/>-produced plan <b>executes</b> — parses, binds, and returns the seeded
///     rows — rather than merely looking right as a string.
///     <para>The planner's own tests assert emitted SQL text. Nothing there can catch a statement that is
///     well-formed but unexecutable, or a parameter marker the provider will not bind. These tests can, and
///     they are the reason the next task starts from a known-good query path.</para>
/// </summary>
public sealed class SqlitePollFixtureTests : IClassFixture<SqlitePollFixture>
{
    private readonly SqlitePollFixture _fixture;

    public SqlitePollFixtureTests(SqlitePollFixture fixture) => _fixture = fixture;

    // ---- the fixture itself ----

    [Fact]
    public void AConnectionFromTheFactory_roundTripsASeededRow()
    {
        // Exactly the reader's seam: create from the abstract factory, assign the connection string, open.
        var connection = _fixture.Factory.CreateConnection();
        connection.ShouldNotBeNull();
        connection.ConnectionString = _fixture.ConnectionString;
        connection.Open();
        using var owned = connection;

        using var command = owned.CreateCommand();
        command.CommandText =
            $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " +
            $"WHERE {SqlitePollFixture.KeyColumn} = @k";
        var parameter = command.CreateParameter();
        parameter.ParameterName = "@k";
        parameter.Value = SqlitePollFixture.PresentKey;
        command.Parameters.Add(parameter);

        Convert.ToDouble(command.ExecuteScalar()).ShouldBe(SqlitePollFixture.PresentValue);
    }

    [Fact]
    public void TheDatabase_survivesTheReadersOpenAndCloseCyclePerPoll()
    {
        // The whole reason the fixture is file-backed: an in-memory database would be gone by "poll" 2.
        for (var poll = 0; poll < 3; poll++)
        {
            using var connection = _fixture.OpenNewConnection();
            using var command = connection.CreateCommand();
            command.CommandText = $"SELECT COUNT(*) FROM {SqlitePollFixture.KeyValueTable}";
            Convert.ToInt64(command.ExecuteScalar()).ShouldBe(3L);
        }
    }

    [Fact]
    public void TheSeed_distinguishesAPresentValue_aNullCell_andAnAbsentKey()
    {
        using var connection = _fixture.OpenNewConnection();

        ReadValue(connection, SqlitePollFixture.PresentKey).ShouldBe(SqlitePollFixture.PresentValue);
        ReadValue(connection, SqlitePollFixture.NullValueKey).ShouldBe(DBNull.Value);   // row present, cell NULL
        ReadValue(connection, SqlitePollFixture.AbsentKey).ShouldBeNull();              // no row at all

        static object? ReadValue(SqliteConnection connection, string key)
        {
            using var command = connection.CreateCommand();
            command.CommandText =
                $"SELECT {SqlitePollFixture.ValueColumn} FROM {SqlitePollFixture.KeyValueTable} " +
                $"WHERE {SqlitePollFixture.KeyColumn} = @k";
            command.Parameters.AddWithValue("@k", key);
            return command.ExecuteScalar();
        }
    }

    // ---- planner-produced plans, actually executed ----

    [Fact]
    public void AKeyValuePlan_executesAgainstTheFixture_andSlicesBackPerMember()
    {
        var plan = SqlGroupPlanner.Plan(
        [
            KvTag("A", SqlitePollFixture.PresentKey),
            KvTag("B", SqlitePollFixture.NullValueKey),
            KvTag("C", SqlitePollFixture.AbsentKey),
        ], new SqliteDialect()).ShouldHaveSingleItem();

        var rows = ExecutePlan(plan);

        // The result set is indexed by the key column, exactly as the reader will slice it.
        var byKey = rows.ToDictionary(
            r => (string)r[SqlitePollFixture.KeyColumn]!, StringComparer.Ordinal);

        byKey.Count.ShouldBe(2);   // the absent key contributes no row — it is not an error, just no data
        byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.ValueColumn]
            .ShouldBe(SqlitePollFixture.PresentValue);
        byKey[SqlitePollFixture.NullValueKey][SqlitePollFixture.ValueColumn]
            .ShouldBeNull();       // present row, NULL cell — distinct from the absent key above
        byKey.ShouldNotContainKey(SqlitePollFixture.AbsentKey);
        byKey[SqlitePollFixture.PresentKey][SqlitePollFixture.TimestampColumn]
            .ShouldBe("2026-07-24T10:00:00Z");
    }

    [Fact]
    public void AWideRowWherePairPlan_executesAgainstTheFixture_andReturnsTheSelectedRow()
    {
        var plan = SqlGroupPlanner.Plan(
        [
            WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.PresentStation),
            WideTag("B", "pressure", selectorValue: SqlitePollFixture.PresentStation),
        ], new SqliteDialect()).ShouldHaveSingleItem();

        // The station id is bound, not inlined — and SQLite coerces the bound string against an INTEGER column.
        plan.Parameters.ShouldBe(new object[] { SqlitePollFixture.PresentStation });

        var row = ExecutePlan(plan).ShouldHaveSingleItem();
        row["oven_temp"].ShouldBe(SqlitePollFixture.PresentStationOvenTemp);
        row["pressure"].ShouldBe(SqlitePollFixture.PresentStationPressure);
    }

    [Fact]
    public void AWideRowWherePairPlan_forAnAbsentSelectorValue_returnsNoRows()
    {
        var plan = SqlGroupPlanner.Plan(
            [WideTag("A", "oven_temp", selectorValue: SqlitePollFixture.AbsentStation)],
            new SqliteDialect()).ShouldHaveSingleItem();

        ExecutePlan(plan).ShouldBeEmpty();
    }

    [Fact]
    public void ATopByTimestampPlan_emitsSqlitesLimitForm_andExecutesToTheNewestRow()
    {
        var plan = SqlGroupPlanner.Plan(
            [WideTag("A", "oven_temp", topByTimestamp: SqlitePollFixture.TimestampColumn)],
            new SqliteDialect()).ShouldHaveSingleItem();

        // The payoff of putting the row limit on ISqlDialect: T-SQL's "TOP 1" does not parse in SQLite, so
        // this statement would throw at ExecuteReader if the planner had kept emitting it inline.
        plan.SqlText.ShouldBe(
            "SELECT \"oven_temp\" FROM \"LatestStatus\" ORDER BY \"sample_ts\" DESC LIMIT 1");
        plan.SqlText.ShouldNotContain("TOP 1");

        var row = ExecutePlan(plan).ShouldHaveSingleItem();
        row["oven_temp"].ShouldBe(SqlitePollFixture.NewestStationOvenTemp);   // station 8, not the first row
    }

    // ---- the dialect ----

    [Fact]
    public void TheDialect_quotesWithDoubleQuotes_andDoublesAnEmbeddedOne()
    {
        var dialect = new SqliteDialect();
        dialect.QuoteIdentifier("oven_temp").ShouldBe("\"oven_temp\"");
        dialect.QuoteIdentifier("a\"b").ShouldBe("\"a\"\"b\"");
        Should.Throw<ArgumentException>(() => dialect.QuoteIdentifier("x\0y"));
        Should.Throw<ArgumentException>(() => dialect.QuoteIdentifier("  "));
    }

    [Fact]
    public void TheDialectsCatalogSql_answersOverTheRealSeededDatabase()
    {
        var dialect = new SqliteDialect();
        using var connection = _fixture.OpenNewConnection();

        Query(dialect.ListSchemasSql).ShouldBe(new[] { SqliteDialect.MainSchema });

        var tables = Query(dialect.ListTablesSql, ("@schema", SqliteDialect.MainSchema));
        tables.ShouldContain(SqlitePollFixture.KeyValueTable);
        tables.ShouldContain(SqlitePollFixture.WideRowTable);
        tables.ShouldAllBe(t => !t.StartsWith("sqlite_", StringComparison.Ordinal));

        var columns = Query(
            dialect.ListColumnsSql,
            ("@schema", SqliteDialect.MainSchema), ("@table", SqlitePollFixture.WideRowTable));
        columns.ShouldBe(new[]
        {
            SqlitePollFixture.WideRowSelectorColumn, "oven_temp", "pressure", SqlitePollFixture.TimestampColumn,
        });

        List<string> Query(string sql, params (string Name, string Value)[] parameters)
        {
            using var command = connection.CreateCommand();
            command.CommandText = sql;
            foreach (var (name, value) in parameters) command.Parameters.AddWithValue(name, value);
            using var reader = command.ExecuteReader();
            var results = new List<string>();
            while (reader.Read()) results.Add(reader.GetString(0));
            return results;
        }
    }

    [Fact]
    public void TheDialect_mapsTheSeededDeclaredTypes_andNeverThrowsOnAnUnknownAffinity()
    {
        var dialect = new SqliteDialect();
        dialect.MapColumnType("TEXT").ShouldBe(DriverDataType.String);
        dialect.MapColumnType("INTEGER").ShouldBe(DriverDataType.Int32);
        // SQLite's REAL is a double — NOT Float32, which is what the same word means in T-SQL.
        dialect.MapColumnType("REAL").ShouldBe(DriverDataType.Float64);
        dialect.MapColumnType("real").ShouldBe(DriverDataType.Float64);
        dialect.MapColumnType("VARCHAR(50)").ShouldBe(DriverDataType.String);   // unparsed ⇒ fallback
        dialect.MapColumnType("some_udt").ShouldBe(DriverDataType.String);
        dialect.MapColumnType("").ShouldBe(DriverDataType.String);
        dialect.MapColumnType(null!).ShouldBe(DriverDataType.String);
    }

    [Fact]
    public void TheDialect_doesNotClaimToBeSqlServer()
        // A test dialect that reported SqlServer would rubber-stamp the T-SQL assumptions it exists to catch.
        => new SqliteDialect().Provider.ShouldNotBe(SqlProvider.SqlServer);

    // ---- helpers ----

    private static SqlTagDefinition KvTag(string name, string keyValue)
        => new(name, SqlTagModel.KeyValue, SqlitePollFixture.KeyValueTable,
            KeyColumn: SqlitePollFixture.KeyColumn, KeyValue: keyValue,
            ValueColumn: SqlitePollFixture.ValueColumn,
            TimestampColumn: SqlitePollFixture.TimestampColumn);

    private static SqlTagDefinition WideTag(
        string name, string columnName, string? selectorValue = null, string? topByTimestamp = null)
        => new(name, SqlTagModel.WideRow, SqlitePollFixture.WideRowTable,
            ColumnName: columnName,
            RowSelectorColumn: selectorValue is null ? null : SqlitePollFixture.WideRowSelectorColumn,
            RowSelectorValue: selectorValue,
            RowSelectorTopByTimestamp: topByTimestamp);

    /// <summary>
    ///     Executes a plan the way the reader will: one fresh connection, the plan's SQL verbatim, its
    ///     parameters bound positionally by name, and the rows projected by
    ///     <see cref="SqlQueryPlan.SelectedColumns"/>.
    /// </summary>
    private List<Dictionary<string, object?>> ExecutePlan(SqlQueryPlan plan)
    {
        using var connection = _fixture.OpenNewConnection();
        using var command = connection.CreateCommand();
        command.CommandText = plan.SqlText;
        for (var i = 0; i < plan.ParameterNames.Count; i++)
            command.Parameters.AddWithValue(plan.ParameterNames[i], plan.Parameters[i] ?? DBNull.Value);

        using var reader = command.ExecuteReader();
        var rows = new List<Dictionary<string, object?>>();
        while (reader.Read())
        {
            var row = new Dictionary<string, object?>(StringComparer.Ordinal);
            foreach (var column in plan.SelectedColumns)
            {
                var ordinal = reader.GetOrdinal(column);
                row[column] = reader.IsDBNull(ordinal) ? null : reader.GetValue(ordinal);
            }

            rows.Add(row);
        }

        return rows;
    }
}
