using System.Data.Common;
using Microsoft.Data.Sqlite;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

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

/// <summary>
///     A <b>test-only</b> <see cref="ISqlDialect"/> over SQLite, so the poll reader and the schema browser can
///     be exercised against a real <see cref="DbConnection"/> — real parameter binding, real type coercion,
///     real NULLs — with no SQL Server and no network. <b>No product project references SQLite.</b>
///     <para>It is also the seam's falsifiability control: it is the only non-SQL-Server
///     <see cref="ISqlDialect"/> in the tree, so it is what proves the planner emits <em>dialect</em> SQL
///     rather than T-SQL with a quoting function attached. A SQLite statement built with T-SQL's
///     <c>TOP 1</c> does not parse, and the fixture tests fail loudly if that regresses.</para>
///     <para><b>Kept public and self-contained on purpose:</b> <c>Driver.Sql.Browser.Tests</c> links this file
///     (<c>&lt;Compile Include="..\..\Driver.Sql.Tests\SqliteDialect.cs" Link="SqliteDialect.cs" /&gt;</c>)
///     rather than referencing this project, so it must not depend on anything else defined here.</para>
/// </summary>
public sealed class SqliteDialect : ISqlDialect
{
    /// <summary>
    ///     The only schema name SQLite's main database answers to. SQLite has no schema namespace — the
    ///     catalog queries accept this one value so the browser's schema level has something real to expand.
    /// </summary>
    public const string MainSchema = "main";

    /// <summary>
    ///     Reports <see cref="SqlProvider.Odbc"/>.
    ///     <para><b>Why not a <c>Sqlite</c> member:</b> <see cref="SqlProvider"/> is a shipped product
    ///     contract, and a test-only dialect must not widen it — every consumer's exhaustive switch would gain
    ///     a case that can never occur in production.</para>
    ///     <para><b>Why <see cref="SqlProvider.Odbc"/> of the existing members:</b> it is the enum's generic,
    ///     not-yet-constructed member, so nothing branches on it today. Crucially it is <em>not</em>
    ///     <see cref="SqlProvider.SqlServer"/> — any future T-SQL-only code path that keys off
    ///     <see cref="Provider"/> stays switched off against this dialect and fails visibly here rather than
    ///     silently applying T-SQL rules to SQLite. Claiming SqlServer would make this dialect a rubber stamp
    ///     for exactly the assumptions it exists to catch.</para>
    /// </summary>
    public SqlProvider Provider => SqlProvider.Odbc;

    /// <inheritdoc/>
    public DbProviderFactory Factory => SqliteFactory.Instance;

    /// <inheritdoc/>
    public string LivenessSql => "SELECT 1";

    /// <summary>
    ///     SQLite spells the row limit at the <em>end</em> of the statement, so the after-<c>SELECT</c>
    ///     position is empty — the mirror image of <c>SqlServerDialect</c>, which is the point of having both
    ///     ends on the seam.
    /// </summary>
    public string SingleRowLimitPrefix => string.Empty;

    /// <summary>
    ///     <c>" LIMIT 1"</c>, leading space included so it appends straight onto the finished statement
    ///     (<c>… ORDER BY "sample_ts" DESC LIMIT 1</c>).
    /// </summary>
    public string SingleRowLimitSuffix => " LIMIT 1";

    /// <summary>
    ///     SQLite has no schema namespace, so the schema level is the single literal
    ///     <see cref="MainSchema"/> — enough to give the browser a real root to expand.
    /// </summary>
    public string ListSchemasSql => $"SELECT '{MainSchema}' AS TABLE_SCHEMA";

    /// <summary>
    ///     SQLite has no per-principal default schema, so an unqualified name always resolves in
    ///     <see cref="MainSchema"/> — the same single schema <see cref="ListSchemasSql"/> reports.
    /// </summary>
    public string DefaultSchemaSql => $"SELECT '{MainSchema}'";

    /// <summary>
    ///     Tables + views from <c>sqlite_schema</c> (the modern name for <c>sqlite_master</c>), with the
    ///     internal <c>sqlite_*</c> objects filtered out and the type folded onto the
    ///     <c>INFORMATION_SCHEMA.TABLES</c> vocabulary the browser already speaks. <c>@schema</c> is bound and
    ///     honoured — anything but <see cref="MainSchema"/> legitimately lists nothing.
    /// </summary>
    public string ListTablesSql =>
        "SELECT name AS TABLE_NAME, " +
        "CASE type WHEN 'view' THEN 'VIEW' ELSE 'BASE TABLE' END AS TABLE_TYPE " +
        "FROM sqlite_schema " +
        "WHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite\\_%' ESCAPE '\\' " +
        $"AND @schema = '{MainSchema}' ORDER BY name";

    /// <summary>
    ///     Columns via the <c>pragma_table_info</c> table-valued function rather than the bare
    ///     <c>PRAGMA table_info(x)</c> statement, because only the function form lets the table name be a
    ///     <b>bound parameter</b> — a bare PRAGMA takes its argument as text, which would put an authored name
    ///     back into a command string and reopen the injection boundary this seam exists to close.
    ///     <c>DATA_TYPE</c> is the column's <em>declared</em> type (SQLite stores affinity, not a type).
    /// </summary>
    public string ListColumnsSql =>
        "SELECT name AS COLUMN_NAME, type AS DATA_TYPE, " +
        "CASE \"notnull\" WHEN 1 THEN 'NO' ELSE 'YES' END AS IS_NULLABLE " +
        "FROM pragma_table_info(@table) " +
        $"WHERE @schema = '{MainSchema}' ORDER BY cid";

    /// <summary>
    ///     Double-quotes one identifier part, doubling any embedded <c>"</c> — SQLite's escape rule, and the
    ///     only metacharacter that could close a quoted identifier early.
    /// </summary>
    /// <remarks>
    ///     Mirrors <c>SqlServerDialect.QuoteIdentifier</c>'s rejections (null / empty / all-whitespace /
    ///     control characters) so a test written against one dialect fails the same way against the other.
    ///     SQLite imposes no identifier length ceiling, so there is no length rule here.
    /// </remarks>
    /// <param name="ident">The bare, unquoted identifier part.</param>
    /// <returns>The double-quote-quoted identifier.</returns>
    /// <exception cref="ArgumentException">The value cannot be a valid SQLite identifier.</exception>
    public string QuoteIdentifier(string ident)
    {
        if (string.IsNullOrWhiteSpace(ident))
            throw new ArgumentException(
                "A SQL identifier must be a non-empty, non-whitespace name.", nameof(ident));

        foreach (var ch in ident)
        {
            if (char.IsControl(ch))
                throw new ArgumentException(
                    "A SQL identifier may not contain control characters (including NUL).", nameof(ident));
        }

        return string.Concat("\"", ident.Replace("\"", "\"\"", StringComparison.Ordinal), "\"");
    }

    /// <summary>
    ///     Folds a SQLite <b>declared</b> column type onto a <see cref="DriverDataType"/>. SQLite is
    ///     dynamically typed and a column's declared type is advisory, which is exactly why the fixture
    ///     declares every seeded column explicitly.
    /// </summary>
    /// <remarks>
    ///     <para><b>Never throws</b>, mirroring <c>SqlServerDialect</c>: an unrecognised (or blank, or
    ///     parameterised like <c>VARCHAR(50)</c>) declaration falls back to
    ///     <see cref="DriverDataType.String"/> so a browse over an oddly-declared table still renders.</para>
    ///     <para><b>Deliberate divergence from SQL Server:</b> <c>REAL</c> maps to
    ///     <see cref="DriverDataType.Float64"/> here. SQLite's REAL is an 8-byte IEEE double, where T-SQL's
    ///     <c>real</c> is 4-byte — mapping it to Float32 would narrow every value the fixture returns.</para>
    /// </remarks>
    /// <param name="sqlDataType">The declared type as <c>pragma_table_info</c> reports it; case-insensitive.</param>
    /// <returns>The mapped driver data type, or <see cref="DriverDataType.String"/> when unrecognised.</returns>
    public DriverDataType MapColumnType(string sqlDataType)
    {
        if (string.IsNullOrWhiteSpace(sqlDataType)) return DriverDataType.String;

        return sqlDataType.Trim().ToLowerInvariant() switch
        {
            "boolean" or "bool" or "bit" => DriverDataType.Boolean,
            "tinyint" or "smallint" or "int2" => DriverDataType.Int16,
            "int" or "integer" or "mediumint" or "int4" => DriverDataType.Int32,
            "bigint" or "int8" => DriverDataType.Int64,
            // SQLite's REAL is a double — deliberately NOT Float32 (see remarks).
            "real" or "double" or "double precision" or "float"
                or "numeric" or "decimal" => DriverDataType.Float64,
            "text" or "clob" or "char" or "varchar" or "nchar" or "nvarchar" => DriverDataType.String,
            "date" or "datetime" or "timestamp" => DriverDataType.DateTime,
            _ => DriverDataType.String,
        };
    }
}
