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>
///     Locks the driver's SQL-injection guarantee against a real database (<see cref="SqlitePollFixture"/>):
///     an authored <em>value</em> is bound as a parameter and can never execute, and an authored
///     <em>identifier</em> — which cannot be parameterized in SQL — is dialect-quoted so a hostile one becomes
///     an inert reference to a nonexistent object rather than executable text. If these pass, a malicious tag
///     cannot alter the database; the seed table is intact after every hostile poll.
///     <para><b>Scope note — this suite deliberately tests the layer BELOW the catalog gate.</b> Design
///     §8.1's catalog gate now exists (Gitea #496): <see cref="SqlCatalogGate"/> validates every authored
///     table/column against the live catalog at Initialize, so in the assembled driver a hostile identifier
///     is rejected up front and its tag reads <see cref="SqlStatusCodes.BadNodeIdUnknown"/> — proven by
///     <c>SqlCatalogGateDriverTests</c>.</para>
///     <para>The tests here construct a <see cref="SqlPollReader"/> <b>directly</b>, with hand-built
///     definitions that never pass through the gate, and that is the point: defence in depth is only worth
///     the name if each layer holds on its own. These assertions pin the <em>quoting backstop</em> — that
///     even with the allow-list bypassed entirely, a hostile identifier becomes an inert reference to a
///     nonexistent object, the query fails after the connection opened, the tag Bad-codes as a query failure
///     (<see cref="SqlStatusCodes.BadCommunicationError"/>), and the seed table survives. Rewriting them to
///     expect <c>BadNodeIdUnknown</c> would delete the only coverage the backstop has and leave the gate as a
///     single point of failure.</para>
/// </summary>
public sealed class SqlInjectionRegressionTests
{
    private static readonly TimeSpan CommandTimeout = TimeSpan.FromSeconds(10);
    private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(15);

    /// <summary>The classic payload: were the key concatenated into text, this would drop the table.</summary>
    private const string DropTablePayload = "'; DROP TABLE TagValues; --";

    [Fact]
    public async Task MaliciousKeyValue_bindsHarmlessly_tableSurvives()
    {
        using var fixture = new SqlitePollFixture();
        var reader = NewReader(fixture, KvTag("Speed", DropTablePayload));

        var snapshots = await reader.ReadAsync(["Speed"], CancellationToken.None);

        // Bound, not executed: it is simply a key that matches no row.
        snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
        // The table is still there, with its seeded rows — the DROP never ran.
        (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0);
    }

    [Fact]
    public async Task MaliciousKeyValue_doesNotEvenDisturbAHealthyNeighbourOnTheSamePoll()
    {
        // A hostile key in one slot must not corrupt a legitimate tag sharing the poll — it binds as its own
        // parameter and matches nothing; the real key still reads.
        using var fixture = new SqlitePollFixture();
        var reader = NewReader(fixture,
            KvTag("Evil", DropTablePayload),
            KvTag("Speed", SqlitePollFixture.PresentKey));

        var snapshots = await reader.ReadAsync(["Evil", "Speed"], CancellationToken.None);

        snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadNoData);
        snapshots[1].Value.ShouldBe(SqlitePollFixture.PresentValue);
        (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBeGreaterThan(0);
    }

    [Fact]
    public async Task MaliciousTableIdentifier_isInert_neverExecutesAndTheSeedSurvives()
    {
        using var fixture = new SqlitePollFixture();
        var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);

        // A table name carrying a statement terminator + DROP. The reader is driven directly, so the
        // catalog gate never sees it and the quoting backstop is on its own: the name is quoted into one
        // nonexistent identifier, the query fails (no such table) with the connection already open, so the
        // tag Bad-codes as a query failure. The DROP never runs. In the assembled driver the gate rejects
        // this first and the tag reads BadNodeIdUnknown instead — see SqlCatalogGateDriverTests.
        var reader = NewReader(fixture,
            new SqlTagDefinition(
                Name: "Evil",
                Model: SqlTagModel.KeyValue,
                Table: "TagValues\"; DROP TABLE TagValues; --",
                KeyColumn: SqlitePollFixture.KeyColumn,
                KeyValue: SqlitePollFixture.PresentKey,
                ValueColumn: SqlitePollFixture.ValueColumn,
                TimestampColumn: SqlitePollFixture.TimestampColumn));

        var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);

        // Bad-coded as a QUERY failure, not BadNodeIdUnknown — this reader was built without the gate.
        SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
        snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadCommunicationError);
        // The load-bearing assertion: the payload did NOT execute. The table and its rows are untouched.
        (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
    }

    [Fact]
    public async Task MaliciousColumnIdentifier_isInert_neverExecutesAndTheSeedSurvives()
    {
        using var fixture = new SqlitePollFixture();
        var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);

        // The same attack via the value column: quoted, so it becomes a nonexistent column reference; the
        // SELECT fails and the tag Bad-codes. Nothing executes.
        var reader = NewReader(fixture,
            new SqlTagDefinition(
                Name: "Evil",
                Model: SqlTagModel.KeyValue,
                Table: SqlitePollFixture.KeyValueTable,
                KeyColumn: SqlitePollFixture.KeyColumn,
                KeyValue: SqlitePollFixture.PresentKey,
                ValueColumn: "num_value\"; DROP TABLE TagValues; --",
                TimestampColumn: SqlitePollFixture.TimestampColumn));

        var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);

        SqlStatusCodes.IsBad(snapshots[0].StatusCode).ShouldBeTrue();
        (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
    }

    [Fact]
    public async Task AControlCharacterIdentifier_isRejectedByQuoting_beforeItReachesTheDatabase()
    {
        // QuoteIdentifier refuses a NUL/control-character identifier outright (it cannot name a real object).
        // The planner throws ArgumentException, which the reader classes as an authoring fault —
        // BadConfigurationError — rather than a database failure. Still inert: the table survives.
        using var fixture = new SqlitePollFixture();
        var seedRows = await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable);

        var reader = NewReader(fixture,
            new SqlTagDefinition(
                Name: "Evil",
                Model: SqlTagModel.KeyValue,
                Table: "Tag\0Values",
                KeyColumn: SqlitePollFixture.KeyColumn,
                KeyValue: SqlitePollFixture.PresentKey,
                ValueColumn: SqlitePollFixture.ValueColumn,
                TimestampColumn: SqlitePollFixture.TimestampColumn));

        var snapshots = await reader.ReadAsync(["Evil"], CancellationToken.None);

        snapshots[0].StatusCode.ShouldBe(SqlStatusCodes.BadConfigurationError);
        (await RowCountAsync(fixture, SqlitePollFixture.KeyValueTable)).ShouldBe(seedRows);
    }

    // ---- helpers ----

    private static SqlPollReader NewReader(SqlitePollFixture fixture, params SqlTagDefinition[] tags)
    {
        var table = tags.ToDictionary(t => t.Name, StringComparer.Ordinal);
        return new SqlPollReader(
            fixture.Factory, fixture.ConnectionString, new SqliteDialect(),
            commandTimeout: CommandTimeout, operationTimeout: OperationTimeout,
            maxConcurrentGroups: 4, nullIsBad: false,
            resolve: rawPath => table.GetValueOrDefault(rawPath));
    }

    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);

    /// <summary>Counts rows in a table over the fixture's own connection — the direct evidence a DROP did not
    /// run. The table name is a test constant, never authored input, so interpolating it here is safe.</summary>
    private static async Task<long> RowCountAsync(SqlitePollFixture fixture, string table)
    {
        await using var command = fixture.Connection.CreateCommand();
        command.CommandText = $"SELECT COUNT(*) FROM \"{table}\"";
        return Convert.ToInt64(await command.ExecuteScalarAsync());
    }
}
