using Microsoft.Data.Sqlite;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Tests;

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

/// <summary>
///     A real, seeded SQLite database standing in for the SQL Server the schema browser walks. It exists so
///     the browse path runs against a genuine catalog — real <c>sqlite_schema</c> rows, real
///     <c>pragma_table_info</c> output, real parameter binding — with no SQL Server and no network.
///     <para><b>Backed by a temporary FILE, not <c>:memory:</c></b>, for the same reason
///     <c>SqlitePollFixture</c> is: an in-memory database dies with its last connection, and several tests
///     here deliberately open a second connection or dispose the session's connection mid-test.</para>
///     <para><b>The seed is a contract.</b> Every table below exists to pin one browse behaviour, and the
///     awkward ones are the point: <see cref="AwkwardTable"/> and <see cref="AwkwardColumn"/> carry the
///     <c>.</c> and <c>|</c> characters that a naive <c>schema.table|column</c> NodeId would mis-parse, and
///     <see cref="HostileTable"/> is a live injection probe — if any catalog query concatenated a name
///     instead of binding it, expanding that table drops <see cref="KeyValueTable"/> and the assertions say
///     so.</para>
/// </summary>
public sealed class SqliteBrowseFixture : IAsyncDisposable
{
    /// <summary>The ordinary key-value table, mirroring the poll fixture's shape.</summary>
    public const string KeyValueTable = "TagValues";

    /// <summary>The <see cref="KeyValueTable"/> column declared <c>REAL</c>, i.e. <c>Float64</c>.</summary>
    public const string RealColumn = "num_value";

    /// <summary>The <see cref="KeyValueTable"/> column declared <c>TEXT</c>, i.e. <c>String</c>.</summary>
    public const string TextColumn = "tag_name";

    /// <summary>A view over <see cref="KeyValueTable"/> — the browser must list views beside tables.</summary>
    public const string ViewName = "TagValuesView";

    /// <summary>
    ///     A table whose name contains BOTH NodeId metacharacters. A <c>schema.table|column</c> NodeId cannot
    ///     represent this unambiguously — <c>main.a.b|c</c> reads equally as schema <c>main.a</c>, table
    ///     <c>b</c>. Legal in SQL Server inside a quoted identifier, so the encoding must survive it.
    /// </summary>
    public const string AwkwardTable = "a.b|c";

    /// <summary>A column name carrying the separator character.</summary>
    public const string AwkwardColumn = "x|y";

    /// <summary>A column name carrying a dot and the escape character.</summary>
    public const string AwkwardColumn2 = @"d.o\t";

    /// <summary>
    ///     A table named as a SQL injection payload. Nothing about it is special to the browser — that is
    ///     exactly the assertion: it is bound as a value, never spliced into a command text.
    /// </summary>
    public const string HostileTable = "'); DROP TABLE TagValues; --";

    /// <summary>The single column of <see cref="HostileTable"/>.</summary>
    public const string HostileColumn = "hostile_col";

    /// <summary>A table whose columns carry types no dialect map recognises.</summary>
    public const string ExoticTable = "Exotic";

    /// <summary>An <see cref="ExoticTable"/> column declared with a type outside the map's vocabulary.</summary>
    public const string ExoticColumn = "shape_col";

    /// <summary>A table name that exists in no catalog — the "vanished table" / zero-column probe.</summary>
    public const string NonExistentTable = "NoSuchTable";

    private readonly string _databasePath;

    private SqliteBrowseFixture(string databasePath, string connectionString, SqliteConnection connection)
    {
        _databasePath = databasePath;
        ConnectionString = connectionString;
        Connection = connection;
    }

    /// <summary>The connection string over the temporary database file.</summary>
    public string ConnectionString { get; }

    /// <summary>
    ///     A long-lived open connection over the seeded database — what tests hand
    ///     <c>SqlBrowseSession</c>. Independent of any other connection over the same file.
    /// </summary>
    public SqliteConnection Connection { get; }

    /// <summary>
    ///     The NodeId of SQLite's one and only schema, encoded exactly as
    ///     <c>SqlBrowseSession.RootAsync</c> emits it.
    /// </summary>
    public static string SchemaNodeId => SqlBrowseNodeId.ForSchema(SqliteDialect.MainSchema);

    /// <summary>Creates the temporary database, applies the schema, and seeds it.</summary>
    /// <returns>The ready fixture.</returns>
    public static async Task<SqliteBrowseFixture> CreateAsync()
    {
        var databasePath = Path.Combine(Path.GetTempPath(), $"otopcua-sql-browse-{Guid.NewGuid():N}.db");
        var connectionString = new SqliteConnectionStringBuilder { DataSource = databasePath }.ToString();
        var connection = new SqliteConnection(connectionString);
        await connection.OpenAsync().ConfigureAwait(false);
        await SeedAsync(connection).ConfigureAwait(false);
        return new SqliteBrowseFixture(databasePath, connectionString, connection);
    }

    /// <summary>Opens a brand-new connection over the same database.</summary>
    /// <returns>An open connection the caller owns.</returns>
    public SqliteConnection OpenNewConnection()
    {
        var connection = new SqliteConnection(ConnectionString);
        connection.Open();
        return connection;
    }

    /// <summary>Counts rows in a table, on a connection of its own so a disposed session cannot affect it.</summary>
    /// <param name="table">The (unquoted) table name to count.</param>
    /// <returns>The row count, or <c>-1</c> when the table does not exist.</returns>
    public long CountRows(string table)
    {
        using var connection = OpenNewConnection();
        using var command = connection.CreateCommand();
        command.CommandText = $"SELECT COUNT(*) FROM \"{table.Replace("\"", "\"\"", StringComparison.Ordinal)}\"";
        try
        {
            return Convert.ToInt64(command.ExecuteScalar(), System.Globalization.CultureInfo.InvariantCulture);
        }
        catch (SqliteException)
        {
            return -1;
        }
    }

    /// <summary>Closes the fixture connection, clears the pool, and deletes the temporary file.</summary>
    /// <returns>A task that represents the asynchronous operation.</returns>
    public async ValueTask DisposeAsync()
    {
        await Connection.DisposeAsync().ConfigureAwait(false);
        // Microsoft.Data.Sqlite pools per connection string; without this the file stays held open and the
        // delete silently fails, leaking one file per test 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 described by this type's constants. Every column is explicitly declared —
    ///     SQLite stores affinity rather than a type, so an undeclared column would give
    ///     <c>pragma_table_info</c> (and therefore <c>MapColumnType</c>) nothing real to report.
    /// </summary>
    private static async Task SeedAsync(SqliteConnection connection)
    {
        await ExecuteAsync(connection, $"""
            CREATE TABLE "{KeyValueTable}" (
                "{TextColumn}" TEXT NOT NULL,
                "{RealColumn}" REAL NULL,
                sample_ts      TEXT NOT NULL
            );
            INSERT INTO "{KeyValueTable}" ("{TextColumn}", "{RealColumn}", sample_ts)
                VALUES ('Line1.Speed', 42.0, '2026-07-24T10:00:00Z');

            CREATE VIEW "{ViewName}" AS SELECT "{TextColumn}", "{RealColumn}" FROM "{KeyValueTable}";

            CREATE TABLE "{ExoticTable}" (
                "{ExoticColumn}" GEOGRAPHY NULL,
                blob_col         BLOB NULL
            );
            """).ConfigureAwait(false);

        // Separate statements: these names must be quoted by the seed itself, so keep them off the
        // interpolation path above where a stray quote would be hard to spot.
        await ExecuteAsync(connection,
            $"CREATE TABLE {Quote(AwkwardTable)} ({Quote(AwkwardColumn)} REAL NULL, {Quote(AwkwardColumn2)} TEXT NULL)")
            .ConfigureAwait(false);

        await ExecuteAsync(connection,
            $"CREATE TABLE {Quote(HostileTable)} ({Quote(HostileColumn)} INTEGER NULL)")
            .ConfigureAwait(false);
    }

    private static string Quote(string identifier) =>
        string.Concat("\"", identifier.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");

    private static async Task ExecuteAsync(SqliteConnection connection, string sql)
    {
        await using var command = connection.CreateCommand();
        command.CommandText = sql;
        await command.ExecuteNonQueryAsync().ConfigureAwait(false);
    }
}
