using System.Data;
using System.Data.Common;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;

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

/// <summary>
///     Proves <see cref="SqlDriverProbe"/> — the AdminUI "Test Connect" liveness check — against the real
///     SQLite fixture. A probe opens a connection, runs the dialect's <c>SELECT 1</c>, and reports green with
///     latency or red with a reason; it <b>never throws</b> (the <see cref="IDriverProbe"/> contract).
///     <para>The probe parses the SAME factory DTO shape as <see cref="SqlDriverFactoryExtensions"/> (R2-11
///     factory parity, mirroring <c>ModbusDriverProbe</c>), so a config that Tests-green and a config that
///     Deploys are the same config. It resolves the <c>connectionStringRef</c> the same way too — and the
///     tests below assert the resolved connection string never reaches the red-result message.</para>
/// </summary>
public sealed class SqlDriverProbeTests
{
    [Fact]
    public async Task Probe_liveConnection_returnsGreen()
    {
        using var fixture = new SqlitePollFixture();
        var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());

        var result = await probe.ProbeAsync(
            ConfigJson(fixture), TimeSpan.FromSeconds(5), CancellationToken.None);

        result.Ok.ShouldBeTrue();
        result.Latency.ShouldNotBeNull();
        result.Latency!.Value.ShouldBeGreaterThanOrEqualTo(TimeSpan.Zero);
    }

    [Fact]
    public async Task Probe_declaresTheSqlDriverType()
        => SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect())
            .DriverType.ShouldBe(SqlDriver.DriverTypeName);

    [Fact]
    public async Task Probe_aBadConnectionString_returnsRed_withoutThrowing()
    {
        // A connection string that resolves fine but points at nothing openable. The probe must catch the
        // provider's failure and hand back a red result, never propagate it.
        var probe = SqlDriverProbe.ForTest(
            new FailingFactory("simulated open failure"), new SqliteDialect());

        var result = await probe.ProbeAsync(
            ConfigJson(connectionString: "Data Source=/no/such/place.db"),
            TimeSpan.FromSeconds(5), CancellationToken.None);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNullOrWhiteSpace();
        result.Latency.ShouldBeNull();
    }

    [Fact]
    public async Task Probe_missingConnectionStringRef_returnsRed_notAThrow()
    {
        var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect());

        var result = await probe.ProbeAsync(
            """{"provider":"SqlServer"}""", TimeSpan.FromSeconds(5), CancellationToken.None);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        result.Message!.ShouldContain("connectionStringRef");
    }

    [Fact]
    public async Task Probe_configThatIsNotJson_returnsRed_notAThrow()
    {
        var probe = SqlDriverProbe.ForTest(new SqlitePollFixture().Factory, new SqliteDialect());

        var result = await probe.ProbeAsync(
            "definitely not json", TimeSpan.FromSeconds(5), CancellationToken.None);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNullOrWhiteSpace();
    }

    [Fact]
    public async Task Probe_providerNumberSpelling_parses_soTestConnectMatchesDeploy()
    {
        // R2-11: the probe and the factory read the SAME DTO with the SAME converter, so a numerically
        // serialised provider Test-Connects exactly as it Deploys.
        using var fixture = new SqlitePollFixture();
        var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());

        var result = await probe.ProbeAsync(
            ConfigWithProviderAsNumber(fixture), TimeSpan.FromSeconds(5), CancellationToken.None);

        result.Ok.ShouldBeTrue();
    }

    [Fact]
    public async Task Probe_neverPutsTheResolvedConnectionStringIntoTheMessage()
    {
        const string secret = "Password=DoNotLeakMeIntoAProbeMessage";
        var connectionString = $"Data Source=/no/such.db;{secret}";
        // The realistic leak shape: a real provider (e.g. SqlException) embeds the connection target in its
        // OWN exception message. This fake reproduces that — it throws with the connection string in the
        // message — so a probe that surfaced ex.Message would leak, and this test would then go red.
        var probe = SqlDriverProbe.ForTest(new LeakyFailingFactory(), new SqliteDialect());

        var result = await probe.ProbeAsync(
            ConfigJson(connectionString: connectionString),
            TimeSpan.FromSeconds(5), CancellationToken.None);

        result.Ok.ShouldBeFalse();
        result.Message.ShouldNotBeNull();
        result.Message!.ShouldNotContain("DoNotLeakMeIntoAProbeMessage");
    }

    [Fact]
    public async Task Probe_honoursCancellation_asRed_notAThrow()
    {
        // A cancelled probe is still a probe result, not an exception the AdminUI has to catch.
        using var fixture = new SqlitePollFixture();
        var probe = SqlDriverProbe.ForTest(fixture.Factory, new SqliteDialect());
        using var cancelled = new CancellationTokenSource();
        await cancelled.CancelAsync();

        var result = await probe.ProbeAsync(ConfigJson(fixture), TimeSpan.FromSeconds(5), cancelled.Token);

        result.Ok.ShouldBeFalse();
    }

    // ---- helpers ----

    /// <summary>A config blob whose connectionStringRef resolves (for the life of the returned scope) to the
    /// fixture's real database, so the probe's SELECT 1 actually runs.</summary>
    private static string ConfigJson(SqlitePollFixture fixture)
        => ConfigJson(connectionString: fixture.ConnectionString);

    private static string ConfigWithProviderAsNumber(SqlitePollFixture fixture)
    {
        var name = SetRef(fixture.ConnectionString);
        return $$"""{"provider":0,"connectionStringRef":"{{name}}"}""";
    }

    /// <summary>
    ///     Builds a config JSON whose <c>connectionStringRef</c> is provisioned to
    ///     <paramref name="connectionString"/> via the process environment — the exact resolution path the
    ///     factory uses. The env var is set for the test process; each call mints a unique ref so parallel
    ///     tests cannot collide, and the value is never unset (a harmless per-run leak of a random name).
    /// </summary>
    private static string ConfigJson(string connectionString)
    {
        var name = SetRef(connectionString);
        return $$"""{"provider":"SqlServer","connectionStringRef":"{{name}}"}""";
    }

    private static string SetRef(string connectionString)
    {
        var name = $"OtOpcUaSqlProbe{Guid.NewGuid():N}";
        Environment.SetEnvironmentVariable(
            SqlConnectionStringResolver.EnvironmentVariableFor(name), connectionString);
        return name;
    }

    /// <summary>A <see cref="DbProviderFactory"/> whose connections throw on open — a database that cannot be
    /// reached, without needing a real unreachable endpoint.</summary>
    private sealed class FailingFactory(string message) : DbProviderFactory
    {
        public override DbConnection CreateConnection() => new FailingConnection(message);
    }

    /// <summary>Like <see cref="FailingFactory"/>, but its open failure embeds the connection string in the
    /// thrown exception's message — the realistic provider shape that a naive <c>ex.Message</c> would leak.</summary>
    private sealed class LeakyFailingFactory : DbProviderFactory
    {
        public override DbConnection CreateConnection() => new FailingConnection(message: null);
    }

    private sealed class FailingConnection(string? message) : DbConnection
    {
        [System.Diagnostics.CodeAnalysis.AllowNull]
        public override string ConnectionString { get; set; } = string.Empty;
        public override string Database => string.Empty;
        public override string DataSource => string.Empty;
        public override string ServerVersion => string.Empty;
        public override ConnectionState State => ConnectionState.Closed;

        // A null ctor message means "embed the connection string" — the leaky provider shape.
        private string FailureMessage => message ?? $"connect failed for {ConnectionString}";

        public override void Open() => throw new InvalidOperationException(FailureMessage);

        public override Task OpenAsync(CancellationToken cancellationToken)
            => throw new InvalidOperationException(FailureMessage);

        public override void Close() { }
        public override void ChangeDatabase(string databaseName) { }
        protected override DbTransaction BeginDbTransaction(System.Data.IsolationLevel isolationLevel)
            => throw new NotSupportedException();
        protected override DbCommand CreateDbCommand() => throw new NotSupportedException();
    }
}
