using System.Data.Common;
using System.Text.Json;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.ExternalSystems;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Types;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
using ZB.MOM.WW.ScadaBridge.StoreAndForward;

namespace ZB.MOM.WW.ScadaBridge.ExternalSystemGateway;

/// <summary>
/// Database access from scripts.
/// Database.Connection("name") — returns ADO.NET SqlConnection (connection pooling).
/// Database.CachedWrite("name", "sql", params) — submits to S&amp;F engine.
/// </summary>
public class DatabaseGateway : IDatabaseGateway
{
    private readonly IExternalSystemRepository _repository;
    private readonly StoreAndForwardService? _storeAndForward;
    private readonly ILogger<DatabaseGateway> _logger;

    /// <summary>
    /// Initializes a new instance of <see cref="DatabaseGateway"/>.
    /// </summary>
    /// <param name="repository">Repository for resolving database connection definitions.</param>
    /// <param name="logger">Logger for diagnostics.</param>
    /// <param name="storeAndForward">Optional store-and-forward service for cached writes; null disables buffering.</param>
    public DatabaseGateway(
        IExternalSystemRepository repository,
        ILogger<DatabaseGateway> logger,
        StoreAndForwardService? storeAndForward = null)
    {
        _repository = repository;
        _logger = logger;
        _storeAndForward = storeAndForward;
    }

    /// <inheritdoc />
    public async Task<DbConnection> GetConnectionAsync(
        string connectionName,
        CancellationToken cancellationToken = default)
    {
        var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
        if (definition == null)
        {
            throw new InvalidOperationException($"Database connection '{connectionName}' not found");
        }

        var connection = CreateConnection(definition.ConnectionString);
        try
        {
            await connection.OpenAsync(cancellationToken);
        }
        catch
        {
            // OpenAsync failed (unreachable server, bad credentials, cancellation) —
            // dispose the just-created connection before the exception propagates so
            // it is not leaked.
            await connection.DisposeAsync();
            throw;
        }
        return connection;
    }

    /// <summary>
    /// Creates the underlying ADO.NET connection for a connection string. Virtual so
    /// tests can substitute a connection whose <c>OpenAsync</c> fails.
    /// </summary>
    /// <param name="connectionString">The ADO.NET connection string.</param>
    /// <returns>A new <see cref="DbConnection"/> for the given connection string.</returns>
    internal virtual DbConnection CreateConnection(string connectionString) =>
        new SqlConnection(connectionString);

    /// <inheritdoc />
    public async Task<ExternalCallResult> CachedWriteAsync(
        string connectionName,
        string sql,
        IReadOnlyDictionary<string, object?>? parameters = null,
        string? originInstanceName = null,
        CancellationToken cancellationToken = default,
        TrackedOperationId? trackedOperationId = null,
        Guid? executionId = null,
        string? sourceScript = null,
        Guid? parentExecutionId = null)
    {
        var definition = await ResolveConnectionAsync(connectionName, cancellationToken);
        if (definition == null)
        {
            throw new InvalidOperationException($"Database connection '{connectionName}' not found");
        }

        if (_storeAndForward == null)
        {
            throw new InvalidOperationException("Store-and-forward service not available for cached writes");
        }

        // Attempt the write IMMEDIATELY and classify the outcome, mirroring
        // ExternalSystemClient.CachedCallAsync. The previous behaviour enqueued
        // every write unconditionally and the S&F retry sweep then retried ALL
        // failures forever — a permanent SQL error (constraint, syntax,
        // permission) was never returned to the script and spun in the buffer
        // indefinitely. Now:
        //   * success                       -> Delivered, NOT buffered;
        //   * PermanentDatabaseException     -> Failed synchronously, NOT buffered;
        //   * TransientDatabaseException     -> buffered to S&F for retry.
        try
        {
            await ExecuteWriteAsync(
                connectionName, definition.ConnectionString, sql, parameters ?? EmptyParameters, cancellationToken)
                .ConfigureAwait(false);

            // Immediate success — the write is done; do not buffer.
            return new ExternalCallResult(Success: true, ResponseJson: null, ErrorMessage: null, WasBuffered: false);
        }
        catch (PermanentDatabaseException ex)
        {
            // Permanent failures are returned to the script and never buffered —
            // mirrors the PermanentExternalSystemException branch on the API path.
            _logger.LogWarning(
                ex,
                "CachedWrite to '{Connection}' failed permanently (SQL error {Number}); returning Failed without buffering.",
                connectionName, ex.SqlErrorNumber);
            return new ExternalCallResult(
                Success: false, ResponseJson: null, ErrorMessage: $"Permanent database error: {ex.Message}", WasBuffered: false);
        }
        catch (TransientDatabaseException ex)
        {
            // Transient failure — hand to S&F so the retry sweep delivers it.
            _logger.LogDebug(
                ex,
                "CachedWrite to '{Connection}' failed transiently (SQL error {Number}); buffering for retry.",
                connectionName, ex.SqlErrorNumber);
        }

        var payload = JsonSerializer.Serialize(new
        {
            ConnectionName = connectionName,
            Sql = sql,
            Parameters = parameters
        });

        // The entity's MaxRetries is a non-nullable int
        // whose default is 0, and the Store-and-Forward engine interprets a stored
        // MaxRetries of 0 as "no limit" (retry forever) — see
        // StoreAndForwardMessage.MaxRetries ("0 = no limit") and the retry-sweep
        // guard `MaxRetries > 0 && ...`. Passing 0 verbatim would turn every
        // unconfigured cached write into an unbounded retry loop. A 0 is treated as
        // "unset" and passed as null so the bounded S&F default applies; the
        // RetryDelay default of TimeSpan.Zero is likewise unset.
        await _storeAndForward.EnqueueAsync(
            StoreAndForwardCategory.CachedDbWrite,
            connectionName,
            payload,
            originInstanceName,
            definition.MaxRetries > 0 ? definition.MaxRetries : null,
            definition.RetryDelay > TimeSpan.Zero ? definition.RetryDelay : null,
            // attemptImmediateDelivery: false — this method already
            // made the write attempt above (the transient-classified failure is
            // exactly why we are buffering). Letting EnqueueAsync re-invoke the
            // delivery handler would execute the same write a second time —
            // mirrors ExternalSystemClient.CachedCallAsync.
            attemptImmediateDelivery: false,
            // Pin the S&F message id to the
            // TrackedOperationId so the retry loop can
            // read it back via StoreAndForwardMessage.Id and emit per-attempt +
            // terminal cached-write telemetry. Null -> S&F mints its own GUID
            // (legacy behaviour).
            messageId: trackedOperationId?.ToString(),
            // Thread the originating script execution's ExecutionId + SourceScript
            // onto the buffered row so the retry-loop cached-write audit rows carry
            // the same provenance the script-side cached rows do.
            executionId: executionId,
            sourceScript: sourceScript,
            // Thread the spawning inbound-API request's ExecutionId onto the
            // buffered row so the retry-loop cached-write audit rows correlate
            // back to the cross-execution chain. Null for a non-routed run.
            parentExecutionId: parentExecutionId);

        // Buffered for retry — mirrors the API path's WasBuffered=true result.
        return new ExternalCallResult(Success: true, ResponseJson: null, ErrorMessage: null, WasBuffered: true);
    }

    /// <summary>
    /// Delivers a buffered CachedDbWrite during a store-and-forward retry
    /// sweep — executes the SQL against the named connection.
    /// </summary>
    /// <remarks>
    /// The outcome is classified, mirroring
    /// <see cref="ExternalSystemClient.DeliverBufferedAsync"/>. Returns
    /// <c>false</c> — so the S&amp;F engine PARKS the message — when the
    /// connection no longer exists, the payload is unreadable, or the SQL fails
    /// with a PERMANENT error (constraint / syntax / permission). A TRANSIENT SQL
    /// error (<see cref="TransientDatabaseException"/>) propagates so the engine
    /// retries. The previous code rethrew on ANY SQL error, so a permanent
    /// failure on the retry path looped forever.
    /// </remarks>
    /// <param name="message">The buffered store-and-forward message to deliver.</param>
    /// <param name="cancellationToken">Cancellation token for the delivery operation.</param>
    /// <returns>A task that resolves to <c>true</c> on success, or <c>false</c> when the message must be parked.</returns>
    /// <exception cref="TransientDatabaseException">Thrown on a transient SQL failure so the engine retries.</exception>
    public async Task<bool> DeliverBufferedAsync(
        StoreAndForwardMessage message, CancellationToken cancellationToken = default)
    {
        // A malformed (not just empty/null-fielded)
        // PayloadJson would otherwise throw `JsonException` here, which the S&F
        // engine treats as a transient failure and retries forever (poison
        // message). Re-running the same deserialization against the same payload
        // will throw deterministically, so JsonException is permanent — log,
        // and return false so the S&F engine parks the message instead.
        CachedWritePayload? payload;
        try
        {
            payload = JsonSerializer.Deserialize<CachedWritePayload>(message.PayloadJson);
        }
        catch (JsonException ex)
        {
            _logger.LogError(
                ex,
                "Buffered CachedDbWrite message {Id} has malformed JSON payload; parking.",
                message.Id);
            return false;
        }

        if (payload == null || string.IsNullOrEmpty(payload.ConnectionName) || string.IsNullOrEmpty(payload.Sql))
        {
            _logger.LogError("Buffered CachedDbWrite message {Id} has an unreadable payload; parking.", message.Id);
            return false;
        }

        var definition = await ResolveConnectionAsync(payload.ConnectionName, cancellationToken);
        if (definition == null)
        {
            _logger.LogError(
                "Buffered DB write to '{Connection}' cannot be delivered — the connection no longer exists; parking.",
                payload.ConnectionName);
            return false;
        }

        // Materialise the buffered JsonElement parameters into CLR values once,
        // then run through the shared ExecuteWriteAsync seam so both the
        // immediate-attempt path and this retry path classify SqlException the
        // same way.
        IReadOnlyDictionary<string, object?> materialisedParameters =
            payload.Parameters == null
                ? EmptyParameters
                : payload.Parameters.ToDictionary(
                    kv => kv.Key, kv => (object?)JsonElementToParameterValue(kv.Value));

        try
        {
            await ExecuteWriteAsync(
                payload.ConnectionName, definition.ConnectionString, payload.Sql, materialisedParameters, cancellationToken)
                .ConfigureAwait(false);
            return true;
        }
        catch (PermanentDatabaseException ex)
        {
            // Permanent — parking is correct; retrying the identical statement
            // cannot succeed. Mirrors ExternalSystemClient.DeliverBufferedAsync
            // returning false on PermanentExternalSystemException.
            _logger.LogError(
                ex,
                "Buffered DB write to '{Connection}' failed permanently (SQL error {Number}); parking.",
                payload.ConnectionName, ex.SqlErrorNumber);
            return false;
        }
        // TransientDatabaseException propagates — the S&F engine retries.
    }

    /// <summary>
    /// Reusable empty parameter map so the no-parameter paths do not allocate a
    /// fresh dictionary each call.
    /// </summary>
    private static readonly IReadOnlyDictionary<string, object?> EmptyParameters =
        new Dictionary<string, object?>();

    /// <summary>
    /// Executes a parameterised SQL write against the given connection
    /// string and classifies the outcome into
    /// <see cref="TransientDatabaseException"/> / <see cref="PermanentDatabaseException"/>,
    /// mirroring the ordered catches of
    /// <see cref="ExternalSystemClient.InvokeHttpAsync"/> on the API path:
    /// caller-requested cancellation propagates unchanged; a <see cref="SqlException"/>
    /// is classified by error number via <see cref="SqlErrorClassifier"/>; a
    /// non-<see cref="SqlException"/> transport/connection outage is classified
    /// transient via <see cref="SqlErrorClassifier.IsTransient(System.Exception)"/>;
    /// genuinely-unexpected exceptions propagate. This is the single classification
    /// seam shared by the immediate <see cref="CachedWriteAsync"/> attempt and the
    /// <see cref="DeliverBufferedAsync"/> retry path. Marked <c>internal virtual</c>
    /// so tests can substitute already-classified outcomes; the raw I/O lives in
    /// the inner <see cref="RunSqlAsync"/> seam so tests can also drive raw outage
    /// exceptions through this classification (without fabricating a
    /// <see cref="SqlException"/>, which has no public constructor).
    /// </summary>
    /// <param name="connectionName">The human-readable connection name, used only for the classified error message (never the connection string — that would leak credentials into logs / script-visible errors).</param>
    /// <param name="connectionString">The ADO.NET connection string to write through.</param>
    /// <param name="sql">The SQL statement to execute.</param>
    /// <param name="parameters">Materialised CLR parameter values (may be empty).</param>
    /// <param name="cancellationToken">Cancellation token for the write.</param>
    /// <returns>A task that completes when the write succeeds.</returns>
    /// <exception cref="OperationCanceledException">Rethrown unchanged when the caller's <paramref name="cancellationToken"/> requested cancellation.</exception>
    /// <exception cref="TransientDatabaseException">Thrown for a transient SQL error number or a non-Sql transport/connection outage.</exception>
    /// <exception cref="PermanentDatabaseException">Thrown for a permanent (or unknown) SQL error number.</exception>
    internal virtual async Task ExecuteWriteAsync(
        string connectionName,
        string connectionString,
        string sql,
        IReadOnlyDictionary<string, object?> parameters,
        CancellationToken cancellationToken)
    {
        // The catch ordering MIRRORS
        // ExternalSystemClient.InvokeHttpAsync exactly so the SQL path classifies
        // a live outage the same way the HTTP path does:
        //   1. caller-requested cancellation propagates UNCHANGED (never a "DB error");
        //   2. a SqlException is classified by error number (transient/permanent);
        //   3. a NON-SqlException transport/connection failure (InvalidOperationException
        //      "connection not open", IOException, SocketException, TimeoutException,
        //      a non-Sql DbException, …) is TRANSIENT — buffered + retried, because a
        //      retry can succeed once the server is reachable. The pre-fix code only
        //      caught SqlException, so these escaped unclassified and crashed the
        //      Script Execution Actor instead of buffering;
        //   4. genuinely-unexpected exceptions (e.g. an authoring ArgumentException)
        //      propagate — same as the HTTP path lets unexpected exceptions escape.
        try
        {
            await RunSqlAsync(connectionString, sql, parameters, cancellationToken).ConfigureAwait(false);
        }
        catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
        {
            // [1] The caller asked to abandon the work — propagate the cancellation
            // unchanged; it must never be reclassified as a transient DB error.
            throw;
        }
        catch (SqlException ex)
        {
            // [2] A caller-token cancellation can surface
            // from the SQL driver as a SqlException (a mid-flight cancel), not an
            // OperationCanceledException, so the [1] filter above never sees it.
            // Re-check the caller's token at the TOP of this block so such a cancel
            // propagates as OperationCanceledException regardless of the driver's
            // exception shape — never reclassified as a permanent DB error (the
            // "-008 cancel-not-reclassified" contract). Version-independent: no need
            // to match a specific SqlException number.
            cancellationToken.ThrowIfCancellationRequested();

            // Otherwise classify by SqlException.Number and rethrow as the
            // strongly-typed transient / permanent failure the callers branch on.
            // The context is the connection NAME, never the connection string.
            throw SqlErrorClassifier.Throw(connectionName, ex);
        }
        catch (Exception ex) when (SqlErrorClassifier.IsTransient(ex))
        {
            // [3] A live outage that did not surface as a SqlException — treat as
            // transient so the caller buffers + retries. The message uses the
            // connection NAME, never the connection string (credential safety).
            throw new TransientDatabaseException(
                $"Transient database error on {connectionName}: {ex.Message}",
                errorNumber: null,
                ex);
        }
    }

    /// <summary>
    /// The raw ADO.NET write — opens the connection, builds the
    /// command, and executes it. Marked <c>internal virtual</c> so tests can throw
    /// RAW outage-shaped exceptions (e.g. <see cref="InvalidOperationException"/>,
    /// <see cref="System.Net.Sockets.SocketException"/>) through the PRODUCTION
    /// classification in <see cref="ExecuteWriteAsync"/>. This is the SQL parallel
    /// of <c>client.SendAsync</c> inside <see cref="ExternalSystemClient.InvokeHttpAsync"/>:
    /// the actual I/O, wrapped by the ordered classification catches in the caller.
    /// </summary>
    /// <param name="connectionString">The ADO.NET connection string to write through.</param>
    /// <param name="sql">The SQL statement to execute.</param>
    /// <param name="parameters">Materialised CLR parameter values (may be empty).</param>
    /// <param name="cancellationToken">Cancellation token for the write.</param>
    /// <returns>A task that completes when the write succeeds.</returns>
    internal virtual async Task RunSqlAsync(
        string connectionString,
        string sql,
        IReadOnlyDictionary<string, object?> parameters,
        CancellationToken cancellationToken)
    {
        await using var connection = new SqlConnection(connectionString);
        await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
        using var command = connection.CreateCommand();
        command.CommandText = sql;
        foreach (var (key, value) in parameters)
        {
            var parameter = command.CreateParameter();
            parameter.ParameterName = key.StartsWith('@') ? key : "@" + key;
            parameter.Value = value ?? DBNull.Value;
            command.Parameters.Add(parameter);
        }
        await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
    }

    // A JSON number that does not fit in Int64 must
    // prefer decimal over double — a script's decimal SQL parameter is
    // serialised as JSON without a type tag, and downcasting it to double on
    // the cached-write retry path silently loses precision (e.g.
    // 1234567890.1234567890 -> 1234567890.1234567 as a binary float). Probe
    // long first (whole-number fast path), then decimal (preserves authored
    // precision for typical money/measurement values), and only fall through
    // to double for genuinely out-of-decimal-range values (very large
    // scientific-notation floats).
    /// <summary>
    /// Converts a <see cref="JsonElement"/> to the most appropriate SQL parameter value,
    /// preferring <c>long</c> → <c>decimal</c> → <c>double</c> for numeric kinds to preserve precision.
    /// </summary>
    /// <param name="element">The JSON element to convert.</param>
    /// <returns>A boxed CLR value suitable for use as an ADO.NET parameter, or <see cref="DBNull.Value"/> for null/undefined.</returns>
    internal static object JsonElementToParameterValue(JsonElement element) => element.ValueKind switch
    {
        JsonValueKind.String => (object?)element.GetString() ?? DBNull.Value,
        JsonValueKind.Number => element.TryGetInt64(out var l)
            ? l
            : element.TryGetDecimal(out var dec)
                ? dec
                : element.GetDouble(),
        JsonValueKind.True => true,
        JsonValueKind.False => false,
        JsonValueKind.Null or JsonValueKind.Undefined => DBNull.Value,
        _ => element.GetRawText()
    };

    private sealed record CachedWritePayload(
        string ConnectionName,
        string Sql,
        Dictionary<string, JsonElement>? Parameters);

    private async Task<DatabaseConnectionDefinition?> ResolveConnectionAsync(
        string connectionName,
        CancellationToken cancellationToken)
    {
        // Name-keyed repository lookup instead of
        // fetch-all-then-filter — connection definitions are resolved on every
        // cached write / connection request, so the repository performs an indexed
        // query rather than loading every connection into memory.
        return await _repository.GetDatabaseConnectionByNameAsync(connectionName, cancellationToken);
    }
}
