using System.Globalization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
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>
///     The design §8.1 catalog gate end-to-end through <see cref="SqlDriver"/>, against the real SQLite
///     catalog the poll fixture creates — real <c>ListSchemas</c>/<c>ListTables</c>/<c>ListColumns</c>
///     round-trips, not a hand-built <see cref="SqlCatalog"/>.
///     <para><b>The two facts that matter most here</b> are the ones a pure unit test cannot show: that a
///     rejected tag still <em>has a node</em> and reads <c>BadNodeIdUnknown</c> (rather than silently
///     vanishing from the address space), and that a catalog the driver cannot read faults Initialize instead
///     of rejecting every tag.</para>
/// </summary>
public sealed class SqlCatalogGateDriverTests
{
    private const string DriverInstanceId = "sql-gate";
    private const string ConfigJson = """{"provider":"SqlServer"}""";

    [Fact]
    public async Task A_tag_naming_a_real_table_and_columns_polls_normally()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        driver.GetHealth().State.ShouldBe(DriverState.Healthy);
        var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
        SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
        snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
    }

    /// <summary>
    ///     §8.1's specified outcome, in full: the tag is refused by the allow-list, so it never reaches a
    ///     query — but its node still exists and reads <c>BadNodeIdUnknown</c>. Dropping the node instead would
    ///     turn a diagnosable Bad quality into a missing address-space entry.
    /// </summary>
    [Fact]
    public async Task A_tag_naming_an_unknown_column_keeps_its_node_and_reads_BadNodeIdUnknown()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture,
            KvEntry("Speed", SqlitePollFixture.PresentKey),
            KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "no_such_column"));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        // The driver is healthy: an authoring typo is not a database fault.
        driver.GetHealth().State.ShouldBe(DriverState.Healthy);

        // The node is still materialized...
        var capture = new CapturingBuilder();
        await ((ITagDiscovery)driver).DiscoverAsync(capture, CancellationToken.None);
        capture.Variables.Select(v => v.Info.FullName).ShouldBe(["Speed", "Bogus"], ignoreOrder: true);

        // ...and it is the rejected tag — and only it — that reads BadNodeIdUnknown.
        var snapshots = await driver.ReadAsync(["Speed", "Bogus"], CancellationToken.None);
        SqlStatusCodes.IsGood(snapshots[0].StatusCode).ShouldBeTrue();
        snapshots[1].StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
    }

    [Fact]
    public async Task A_tag_naming_an_unknown_table_reads_BadNodeIdUnknown()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture, KvEntry("Bogus", SqlitePollFixture.PresentKey, table: "NoSuchTable"));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        (await driver.ReadAsync(["Bogus"], CancellationToken.None))
            .ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);
    }

    /// <summary>
    ///     The injection shape #496 exists to close. Before the gate, this name was bracket-quoted into a real
    ///     query that failed only once the connection was open; now it never reaches a query at all.
    /// </summary>
    [Fact]
    public async Task A_hostile_table_name_never_reaches_a_query()
    {
        using var fixture = new SqlitePollFixture();
        var logger = new CapturingLogger();
        await using var driver = NewDriver(
            fixture, logger,
            KvEntry("Evil", SqlitePollFixture.PresentKey, table: "TagValues\"; DROP TABLE TagValues--"));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        (await driver.ReadAsync(["Evil"], CancellationToken.None))
            .ShouldHaveSingleItem().StatusCode.ShouldBe(SqlStatusCodes.BadNodeIdUnknown);

        // The fixture's table is untouched — proven by a second tag still reading through it.
        await using var honest = NewDriver(fixture, KvEntry("Speed", SqlitePollFixture.PresentKey));
        await honest.InitializeAsync(ConfigJson, CancellationToken.None);
        var snapshot = (await honest.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
        SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();

        logger.Entries.ShouldContain(e =>
            e.Level == LogLevel.Warning && e.Message.Contains("rejected by the catalog gate", StringComparison.Ordinal));
    }

    /// <summary>
    ///     A node that goes Bad with nothing in the log is unsupportable, so every drop names the tag, the
    ///     field and the reason.
    /// </summary>
    [Fact]
    public async Task Every_rejection_is_logged_with_the_tag_the_field_and_the_reason()
    {
        using var fixture = new SqlitePollFixture();
        var logger = new CapturingLogger();
        await using var driver = NewDriver(
            fixture, logger, KvEntry("Bogus", SqlitePollFixture.PresentKey, valueColumn: "num_valeu"));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        var warning = logger.Entries
            .Where(e => e.Level == LogLevel.Warning)
            .Select(e => e.Message)
            .ShouldHaveSingleItem();
        warning.ShouldContain("Bogus");
        warning.ShouldContain(nameof(SqlTagDefinition.ValueColumn));
        warning.ShouldContain("num_valeu");
    }

    /// <summary>
    ///     Case-insensitive authoring has always worked on SQL Server's default collation, so the gate must
    ///     accept it — and it substitutes the catalog's spelling, which is what makes the emitted SQL carry
    ///     catalog strings rather than operator input.
    /// </summary>
    [Fact]
    public async Task A_case_variant_identifier_is_accepted_and_polls()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(
            fixture,
            KvEntry(
                "Speed", SqlitePollFixture.PresentKey,
                table: SqlitePollFixture.KeyValueTable.ToUpperInvariant(),
                valueColumn: SqlitePollFixture.ValueColumn.ToUpperInvariant()));

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        var snapshot = (await driver.ReadAsync(["Speed"], CancellationToken.None)).ShouldHaveSingleItem();
        SqlStatusCodes.IsGood(snapshot.StatusCode).ShouldBeTrue();
        snapshot.Value.ShouldBe(SqlitePollFixture.PresentValue);
    }

    /// <summary>
    ///     A driver with nothing authored has nothing to validate; issuing catalog queries to prove that would
    ///     be a round-trip that can only fail.
    /// </summary>
    [Fact]
    public async Task A_driver_with_no_authored_tags_initializes_without_touching_the_catalog()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = NewDriver(fixture);

        await driver.InitializeAsync(ConfigJson, CancellationToken.None);

        driver.GetHealth().State.ShouldBe(DriverState.Healthy);
    }

    /// <summary>
    ///     <b>The fail-closed rule.</b> A catalog that cannot be read is the ABSENCE of evidence about the
    ///     tags, not evidence against them. Rejecting every tag would serve a confidently-empty address space
    ///     and send the operator hunting typos that do not exist; faulting Initialize instead lands
    ///     <c>DriverInstanceActor</c> in Reconnecting with its retry timer running.
    /// </summary>
    [Fact]
    public async Task A_catalog_that_cannot_be_read_faults_Initialize_rather_than_rejecting_every_tag()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = new SqlDriver(
            new SqlDriverOptions
            {
                RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
                OperationTimeout = TimeSpan.FromSeconds(15),
                CommandTimeout = TimeSpan.FromSeconds(10),
            },
            DriverInstanceId,
            new CatalogSqlOverride("SELECT this is not valid sql"),
            fixture.ConnectionString,
            factory: fixture.Factory,
            logger: NullLogger<SqlDriver>.Instance);

        var thrown = await Should.ThrowAsync<InvalidOperationException>(
            async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));

        // The operator surface names the stage that actually failed — "reached it but could not read the
        // catalog" and "could not reach it" send an operator to different systems.
        thrown.Message.ShouldContain("catalog");
        driver.GetHealth().State.ShouldBe(DriverState.Faulted);
    }

    /// <summary>
    ///     Zero visible schemas is a grant problem, not an empty database, so it must fault rather than reject
    ///     every tag for an authoring fault the operator does not have.
    /// </summary>
    [Fact]
    public async Task A_catalog_reporting_no_schemas_at_all_faults_Initialize()
    {
        using var fixture = new SqlitePollFixture();
        await using var driver = new SqlDriver(
            new SqlDriverOptions
            {
                RawTags = [KvEntry("Speed", SqlitePollFixture.PresentKey)],
                OperationTimeout = TimeSpan.FromSeconds(15),
                CommandTimeout = TimeSpan.FromSeconds(10),
            },
            DriverInstanceId,
            new CatalogSqlOverride("SELECT 'x' AS TABLE_SCHEMA WHERE 1 = 0"),
            fixture.ConnectionString,
            factory: fixture.Factory,
            logger: NullLogger<SqlDriver>.Instance);

        await Should.ThrowAsync<InvalidOperationException>(
            async () => await driver.InitializeAsync(ConfigJson, CancellationToken.None));

        driver.GetHealth().State.ShouldBe(DriverState.Faulted);
    }

    // ---- helpers ----

    /// <summary>
    ///     Delegates every member to <see cref="SqliteDialect"/> except <see cref="ListSchemasSql"/>, so the
    ///     catalog-load failure modes can be driven against an otherwise-real dialect.
    /// </summary>
    /// <remarks>
    ///     A decorator rather than a subclass: <see cref="SqliteDialect"/> is sealed, and even if it were not,
    ///     <c>new</c>-hiding a property would leave interface dispatch calling the base — the substitution
    ///     would silently not happen and both tests below would pass for the wrong reason.
    /// </remarks>
    private sealed class CatalogSqlOverride(string listSchemasSql) : ISqlDialect
    {
        private static readonly SqliteDialect Inner = new();

        public SqlProvider Provider => Inner.Provider;

        public System.Data.Common.DbProviderFactory Factory => Inner.Factory;

        public string LivenessSql => Inner.LivenessSql;

        public string SingleRowLimitPrefix => Inner.SingleRowLimitPrefix;

        public string SingleRowLimitSuffix => Inner.SingleRowLimitSuffix;

        public string ListSchemasSql { get; } = listSchemasSql;

        public string DefaultSchemaSql => Inner.DefaultSchemaSql;

        public string ListTablesSql => Inner.ListTablesSql;

        public string ListColumnsSql => Inner.ListColumnsSql;

        public string QuoteIdentifier(string ident) => Inner.QuoteIdentifier(ident);

        public DriverDataType MapColumnType(string sqlDataType) => Inner.MapColumnType(sqlDataType);
    }

    private static SqlDriver NewDriver(SqlitePollFixture fixture, params RawTagEntry[] rawTags)
        => NewDriver(fixture, new CapturingLogger(), rawTags);

    private static SqlDriver NewDriver(
        SqlitePollFixture fixture, CapturingLogger logger, params RawTagEntry[] rawTags)
        => new(
            new SqlDriverOptions
            {
                RawTags = rawTags,
                OperationTimeout = TimeSpan.FromSeconds(15),
                CommandTimeout = TimeSpan.FromSeconds(10),
            },
            DriverInstanceId,
            new SqliteDialect(),
            fixture.ConnectionString,
            factory: fixture.Factory,
            logger: logger);

    /// <summary>One authored raw tag, with the table and value column overridable so the gate can be exercised.</summary>
    private static RawTagEntry KvEntry(
        string rawPath,
        string keyValue,
        string? table = null,
        string? valueColumn = null)
        => new(rawPath, string.Create(CultureInfo.InvariantCulture, $$"""
            {
              "driver": "Sql",
              "model": "KeyValue",
              "table": "{{(table ?? SqlitePollFixture.KeyValueTable).Replace("\"", "\\\"", StringComparison.Ordinal)}}",
              "keyColumn": "{{SqlitePollFixture.KeyColumn}}",
              "keyValue": "{{keyValue}}",
              "valueColumn": "{{valueColumn ?? SqlitePollFixture.ValueColumn}}",
              "timestampColumn": "{{SqlitePollFixture.TimestampColumn}}"
            }
            """), WriteIdempotent: false);

    /// <summary>Records everything the driver streams into the address space.</summary>
    private sealed class CapturingBuilder : IAddressSpaceBuilder
    {
        /// <summary>The variables registered, in order.</summary>
        public List<(string BrowseName, DriverAttributeInfo Info)> Variables { get; } = [];

        public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;

        public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
        {
            Variables.Add((browseName, attributeInfo));
            return new Handle(attributeInfo.FullName);
        }

        public void AddProperty(string browseName, DriverDataType dataType, object? value) { }

        private sealed class Handle(string fullReference) : IVariableHandle
        {
            public string FullReference => fullReference;

            public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new Sink();

            private sealed class Sink : IAlarmConditionSink
            {
                public void OnTransition(AlarmEventArgs args) { }
            }
        }
    }

    /// <summary>Records every log record, level + rendered message.</summary>
    private sealed class CapturingLogger : ILogger<SqlDriver>
    {
        public List<(LogLevel Level, string Message)> Entries { get; } = [];

        public IDisposable BeginScope<TState>(TState state) where TState : notnull => NullScope.Instance;

        public bool IsEnabled(LogLevel logLevel) => true;

        public void Log<TState>(
            LogLevel logLevel, EventId eventId, TState state, Exception? exception,
            Func<TState, Exception?, string> formatter)
            => Entries.Add((logLevel, formatter(state, exception)));

        private sealed class NullScope : IDisposable
        {
            public static NullScope Instance { get; } = new();

            public void Dispose() { }
        }
    }
}
