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

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

/// <summary>
///     The AdminUI "Test Connect" liveness check for the <c>Sql</c> driver: open a connection, run the
///     dialect's <c>SELECT 1</c> (<see cref="ISqlDialect.LivenessSql"/>) under a bounded deadline, and report
///     green with latency or red with a reason. Mirrors <c>ModbusDriverProbe</c>.
///     <para><b>Parses the SAME factory DTO with the SAME converter as
///     <see cref="SqlDriverFactoryExtensions"/></b> (R2-11, 05/CONV-2 — the OpcUaClient parity rule), so a
///     config the operator Test-Connects is byte-for-byte the config that Deploys: a numerically serialised
///     <c>provider</c> parses here exactly as it does at the factory, and the same
///     <c>connectionStringRef</c> resolves the same way.</para>
///     <para><b>Never throws</b> (the <see cref="IDriverProbe"/> contract). Every failure — malformed JSON, a
///     missing <c>connectionStringRef</c>, an unprovisioned connection string, a provider open failure, a
///     timeout, a cancellation — becomes a red <see cref="DriverProbeResult"/>, so the AdminUI has nothing to
///     catch.</para>
///     <para><b>The resolved connection string never reaches the result message.</b> A red result names the
///     failure class, never the string — the same credential discipline the factory and the driver keep.</para>
/// </summary>
public sealed class SqlDriverProbe : IDriverProbe
{
    /// <summary>
    ///     Selects the provider seam by <see cref="SqlProvider"/>. In production this constructs the real
    ///     <see cref="SqlServerDialect"/>; <see cref="ForTest"/> injects a test dialect (and factory) so the
    ///     probe can run against the SQLite fixture with no SQL Server and no network.
    /// </summary>
    private readonly Func<SqlProvider, ISqlDialect> _dialectFor;

    /// <summary>
    ///     Overrides the dialect's own <see cref="ISqlDialect.Factory"/> when non-null — the injection point
    ///     the SQLite tests use to hand in <c>SqliteFactory</c> while keeping the test dialect's
    ///     catalog SQL. Null in production: the dialect's own factory is used.
    /// </summary>
    private readonly DbProviderFactory? _factoryOverride;

    /// <summary>Initializes a new <see cref="SqlDriverProbe"/> that constructs the real SQL Server dialect.</summary>
    public SqlDriverProbe()
        : this(DefaultDialectFor, factoryOverride: null)
    {
    }

    private SqlDriverProbe(Func<SqlProvider, ISqlDialect> dialectFor, DbProviderFactory? factoryOverride)
    {
        _dialectFor = dialectFor;
        _factoryOverride = factoryOverride;
    }

    /// <summary>
    ///     Test seam: a probe that always uses <paramref name="dialect"/> and opens connections from
    ///     <paramref name="factory"/>, so the SQLite fixture's create → open → <c>SELECT 1</c> → close cycle
    ///     runs unmodified.
    /// </summary>
    /// <param name="factory">The provider factory the probe opens connections from.</param>
    /// <param name="dialect">The dialect whose <see cref="ISqlDialect.LivenessSql"/> the probe runs.</param>
    /// <returns>A probe wired to the supplied factory + dialect.</returns>
    /// <exception cref="ArgumentNullException">A required argument is null.</exception>
    internal static SqlDriverProbe ForTest(DbProviderFactory factory, ISqlDialect dialect)
    {
        ArgumentNullException.ThrowIfNull(factory);
        ArgumentNullException.ThrowIfNull(dialect);
        return new SqlDriverProbe(_ => dialect, factory);
    }

    /// <inheritdoc />
    public string DriverType => SqlDriver.DriverTypeName;

    /// <inheritdoc />
    public async Task<DriverProbeResult> ProbeAsync(string configJson, TimeSpan timeout, CancellationToken ct)
    {
        // Parse + resolve first: a config that cannot even be read is a red result, not a connection attempt.
        SqlDriverConfigDto? dto;
        try
        {
            dto = System.Text.Json.JsonSerializer.Deserialize<SqlDriverConfigDto>(
                configJson, SqlDriverFactoryExtensions.JsonOptions);
        }
        catch (Exception ex) when (ex is System.Text.Json.JsonException or ArgumentNullException)
        {
            return new DriverProbeResult(false, $"Config JSON is invalid: {ex.Message}", null);
        }

        if (dto is null)
            return new DriverProbeResult(false, "Config JSON deserialized to null.", null);
        if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
            return new DriverProbeResult(false, "Config has no connectionStringRef to resolve.", null);

        ISqlDialect dialect;
        string connectionString;
        try
        {
            dialect = _dialectFor(dto.Provider);
            connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
        }
        catch (InvalidOperationException ex)
        {
            // Resolve throws with only the ref + env-var name — no secret in the message, so surfacing it is
            // safe and actionable (it names the environment variable the operator must set).
            return new DriverProbeResult(
                false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
        }

        var factory = _factoryOverride ?? dialect.Factory;
        return await RunLivenessAsync(factory, dialect, connectionString, timeout, ct).ConfigureAwait(false);
    }

    /// <summary>
    ///     Opens one connection under a linked CTS bounded by <paramref name="timeout"/>, runs the dialect's
    ///     liveness statement, and returns green with latency. Any failure becomes a red result whose message
    ///     names the failure class — <b>never the connection string</b> (a provider exception can embed the
    ///     data source, so its <c>.Message</c> is deliberately not surfaced).
    /// </summary>
    private static async Task<DriverProbeResult> RunLivenessAsync(
        DbProviderFactory factory, ISqlDialect dialect, string connectionString, TimeSpan timeout,
        CancellationToken ct)
    {
        var stopwatch = Stopwatch.StartNew();
        using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
        if (timeout > TimeSpan.Zero) cts.CancelAfter(timeout);

        try
        {
            var connection = factory.CreateConnection()
                             ?? throw new InvalidOperationException(
                                 $"the {dialect.Provider} provider factory returned no connection.");
            await using (connection.ConfigureAwait(false))
            {
                connection.ConnectionString = connectionString;
                await connection.OpenAsync(cts.Token).ConfigureAwait(false);

                var command = connection.CreateCommand();
                await using (command.ConfigureAwait(false))
                {
                    command.CommandText = dialect.LivenessSql;
                    await command.ExecuteScalarAsync(cts.Token).ConfigureAwait(false);
                }
            }

            stopwatch.Stop();
            return new DriverProbeResult(true, "SQL SELECT 1 OK", stopwatch.Elapsed);
        }
        catch (OperationCanceledException) when (ct.IsCancellationRequested)
        {
            return new DriverProbeResult(false, "Probe cancelled.", null);
        }
        catch (OperationCanceledException)
        {
            return new DriverProbeResult(
                false, $"Probe timed out after {timeout.TotalSeconds:F0}s.", null);
        }
        catch (Exception ex)
        {
            // The provider's own message can carry the data source (host/database), which is not a secret but
            // is more than the operator needs — and keeping the message to a fixed class is what guarantees no
            // credential-bearing connection string can ever slip through. Name the exception TYPE only.
            return new DriverProbeResult(
                false, $"Could not run the SQL liveness check ({ex.GetType().Name}).", null);
        }
    }

    /// <summary>Constructs the production dialect for a provider; v1 supports SQL Server only.</summary>
    private static ISqlDialect DefaultDialectFor(SqlProvider provider) => provider switch
    {
        SqlProvider.SqlServer => new SqlServerDialect(),
        _ => throw new InvalidOperationException(
            $"provider '{provider}' is not available in this build (only SqlServer is supported)."),
    };
}
