using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Hosting;
using ZB.MOM.WW.OtOpcUa.Driver.Sql.Contracts;

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

/// <summary>
///     Static factory registration helper for <see cref="SqlDriver"/> — the seam between a deployed
///     <c>DriverConfig</c> blob and a live driver instance. The Server's composition root calls
///     <see cref="Register"/> once at startup; the bootstrapper then materialises <c>Sql</c> DriverInstance
///     rows into driver instances. Mirrors <c>ModbusDriverFactoryExtensions</c>.
///     <para><b>This type owns the config validation the layers below deliberately skip.</b>
///     <see cref="SqlPollReader"/> and <see cref="SqlDriver"/> stay usable with an inverted
///     <c>operationTimeout</c>/<c>commandTimeout</c> pair on purpose (the frozen-database tests need that
///     shape), so the factory is the only place a deployment can be stopped before it silently loses one of
///     its two independent deadlines. Every knob is checked here, once, at construction.</para>
///     <para><b>Enum fields are read leniently and written as names.</b> <see cref="JsonOptions"/> carries a
///     <see cref="JsonStringEnumConverter"/>, so a <c>provider</c> authored as an ordinal still parses and a
///     round-trip emits <c>"SqlServer"</c> — the systemic AdminUI defect where a page serialised an enum
///     numerically while the consuming DTO was string-typed, faulting the driver at deploy time.</para>
///     <para><b>The resolved connection string is a secret and never leaves this method except into the
///     driver.</b> Every log line and every exception message here names the <c>connectionStringRef</c>, the
///     environment variable, or <see cref="SqlDriver.Endpoint"/>'s credential-free <c>server/database</c>
///     rendering — never the string itself.</para>
/// </summary>
public static class SqlDriverFactoryExtensions
{
    /// <summary>
    ///     The driver-type string this factory registers under — chained off
    ///     <see cref="SqlDriver.DriverTypeName"/>, which is
    ///     <see cref="ZB.MOM.WW.OtOpcUa.Core.Abstractions.DriverTypeNames.Sql"/>.
    /// </summary>
    public const string DriverTypeName = SqlDriver.DriverTypeName;

    /// <summary>
    ///     How a <c>Sql</c> config blob is read.
    ///     <list type="bullet">
    ///         <item><see cref="JsonStringEnumConverter"/> — accepts an enum written as a name OR as an
    ///         ordinal, and always writes the name (the enum-serialization guard).</item>
    ///         <item><see cref="JsonUnmappedMemberHandling.Skip"/> — a blob authored against a different
    ///         schema version must not brick the driver.</item>
    ///         <item><see cref="JsonNamingPolicy.CamelCase"/> — the authored spelling
    ///         (<c>connectionStringRef</c>, <c>rawTags</c>). Reads are case-insensitive anyway; the policy is
    ///         what makes a <em>write</em> round-trip to the authored shape.</item>
    ///     </list>
    /// </summary>
    internal static readonly JsonSerializerOptions JsonOptions = new()
    {
        PropertyNameCaseInsensitive = true,
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        ReadCommentHandling = JsonCommentHandling.Skip,
        AllowTrailingCommas = true,
        UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip,
        Converters = { new JsonStringEnumConverter() },
    };

    /// <summary>The serializer options, exposed so tests can assert the enum guard on a real round-trip.</summary>
    internal static JsonSerializerOptions JsonOptionsForTest => JsonOptions;

    /// <summary>
    ///     Registers the <c>Sql</c> factory with the driver registry. The optional
    ///     <paramref name="loggerFactory"/> is captured at registration time and used to build a logger per
    ///     driver instance; without it the driver runs with the null logger.
    /// </summary>
    /// <param name="registry">The driver factory registry to register with.</param>
    /// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
    /// <exception cref="ArgumentNullException"><paramref name="registry"/> is null.</exception>
    public static void Register(DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null)
    {
        ArgumentNullException.ThrowIfNull(registry);
        registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory));
    }

    /// <summary>Builds one driver instance from its config blob. For the Server bootstrapper and tests.</summary>
    /// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
    /// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
    /// <returns>The constructed <see cref="SqlDriver"/>.</returns>
    public static SqlDriver CreateInstance(string driverInstanceId, string driverConfigJson)
        => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory: null);

    /// <summary>
    ///     Parses a Sql <c>DriverConfig</c> JSON document into <b>every</b> dependency the driver derives
    ///     from config: the typed options, the <see cref="ISqlDialect"/> the <c>provider</c> key selects, and
    ///     the connection string resolved from <c>connectionStringRef</c>.
    ///     <para>Returned together because they must be adopted together. Re-parsing options alone on a
    ///     reinitialize would run a NEW tag set against the OLD connection and dialect (Gitea #516) — a
    ///     half-applied config change is worse than a discarded one, because it looks like it worked.</para>
    ///     <para>Re-running this on reinitialize also re-resolves the connection string from its environment
    ///     variable, so a rotated credential is picked up without a process restart.</para>
    /// </summary>
    /// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
    /// <param name="driverConfigJson">The driver configuration JSON.</param>
    /// <returns>The parsed options, dialect and resolved connection string.</returns>
    /// <exception cref="InvalidOperationException">The config is malformed or names no connection string.</exception>
    internal static SqlDriverBinding ParseBinding(string driverInstanceId, string driverConfigJson)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId);
        ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson);

        var dto = Deserialize(driverInstanceId, driverConfigJson);

        if (string.IsNullOrWhiteSpace(dto.ConnectionStringRef))
        {
            throw new InvalidOperationException(
                $"Sql driver config for '{driverInstanceId}' is missing the required connectionStringRef. " +
                $"Author the NAME of a connection string (the value itself is supplied out-of-band through " +
                $"the '{SqlConnectionStringResolver.EnvironmentVariablePrefix}<ref>' environment variable).");
        }

        var dialect = CreateDialect(dto.Provider, driverInstanceId);

        // Resolved BEFORE the knob validation in CreateInstance, so that every subsequent throw is a path
        // that HAS the secret in hand — which is exactly where a careless interpolation would leak it.
        var connectionString = SqlConnectionStringResolver.Resolve(dto.ConnectionStringRef!);
        return new SqlDriverBinding(BuildOptions(dto, driverInstanceId), dialect, connectionString, dto);
    }

    /// <summary>
    ///     Logger-aware overload — used by <see cref="Register"/>'s closure when wired through DI.
    ///     <para>Constructs the driver only: the driver builds its own <see cref="SqlPollReader"/>, because
    ///     the reader's <c>resolve</c> delegate must read the driver's live authored-tag table, which does not
    ///     exist until the driver does.</para>
    ///     <para>No I/O happens here. The database is first contacted by
    ///     <see cref="SqlDriver.InitializeAsync"/>, so a database that is merely down yields a driver in
    ///     Reconnecting rather than a deployment that cannot be constructed.</para>
    /// </summary>
    /// <param name="driverInstanceId">The central config DB's identity for this driver instance.</param>
    /// <param name="driverConfigJson">The instance's <c>DriverConfig</c> JSON blob.</param>
    /// <param name="loggerFactory">Optional logger factory for creating loggers per driver instance.</param>
    /// <returns>The constructed <see cref="SqlDriver"/>.</returns>
    /// <exception cref="ArgumentException">An argument is null, empty or whitespace.</exception>
    /// <exception cref="InvalidOperationException">
    ///     The blob is not valid JSON, omits <c>connectionStringRef</c>, names a provider this build cannot
    ///     construct, carries an invalid knob, or the referenced connection string is not provisioned.
    /// </exception>
    public static SqlDriver CreateInstance(
        string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory)
    {
        var binding = ParseBinding(driverInstanceId, driverConfigJson);
        var dto = binding.Dto;
        var dialect = binding.Dialect;
        var connectionString = binding.ConnectionString;
        var options = binding.Options;

        var logger = (ILogger?)loggerFactory?.CreateLogger(typeof(SqlDriverFactoryExtensions).FullName!)
                     ?? NullLogger.Instance;

        if (dto.AllowWrites is true)
        {
            // Inert by construction (SqlDriver does not implement IWritable), so failing the driver over it
            // would brick a deployment for a flag that cannot do harm. The operator who believes writes are
            // on, however, can — so this is loud.
            logger.LogWarning(
                "Sql driver {DriverInstanceId} authored allowWrites=true, which this build ignores: the Sql " +
                "driver is read-only (it implements no write capability), so no configuration can enable a " +
                "write. Remove the flag, or expect reads only.",
                driverInstanceId);
        }

        var driver = new SqlDriver(
            options,
            driverInstanceId,
            dialect,
            connectionString,
            factory: null,
            logger: loggerFactory?.CreateLogger<SqlDriver>(),
            // Hand the driver the SAME parse it was built with, so InitializeAsync can re-derive options,
            // dialect and connection string together on a reinitialize (#516). Re-resolving the connection
            // string also picks up a rotated credential without a process restart.
            rebind: json => ParseBinding(driverInstanceId, json));

        // Endpoint is the credential-free server/database rendering — the ONLY form of the connection string
        // permitted outside the provider call.
        logger.LogInformation(
            "Sql driver {DriverInstanceId} configured for {Provider} at {Endpoint} with {TagCount} " +
            "authored tag(s), poll {PollInterval}, operation timeout {OperationTimeout}.",
            driverInstanceId, dto.Provider, driver.Endpoint, options.RawTags.Count,
            options.DefaultPollInterval, options.OperationTimeout);

        return driver;
    }

    /// <summary>Deserialises the blob, turning a malformed one into an actionable, instance-named failure.</summary>
    private static SqlDriverConfigDto Deserialize(string driverInstanceId, string driverConfigJson)
    {
        try
        {
            return JsonSerializer.Deserialize<SqlDriverConfigDto>(driverConfigJson, JsonOptions)
                   ?? throw new InvalidOperationException(
                       $"Sql driver config for '{driverInstanceId}' deserialised to null");
        }
        catch (JsonException ex)
        {
            throw new InvalidOperationException(
                $"Sql driver config for '{driverInstanceId}' is not valid JSON: {ex.Message}", ex);
        }
    }

    /// <summary>
    ///     Selects the provider seam.
    ///     <para>v1 constructs <see cref="SqlProvider.SqlServer"/> only. The other members exist on the shipped
    ///     enum as reserved names; authoring one is a config mistake that must fail at construction with a
    ///     message that says so, rather than silently degrading to T-SQL against a non-SQL-Server database.</para>
    /// </summary>
    private static ISqlDialect CreateDialect(SqlProvider provider, string driverInstanceId)
        => provider switch
        {
            SqlProvider.SqlServer => new SqlServerDialect(),
            _ => throw new InvalidOperationException(
                $"Sql driver config for '{driverInstanceId}' names provider '{provider}', which is not " +
                $"available in this build. Only '{nameof(SqlProvider.SqlServer)}' is supported."),
        };

    /// <summary>
    ///     Applies the documented defaults and validates every knob (design §5.1 / §8.3).
    ///     <para>Internal so the defaults and the rejections can be asserted directly, without a database or a
    ///     provisioned connection string standing between the test and the rule.</para>
    /// </summary>
    /// <param name="dto">The deserialised config blob.</param>
    /// <param name="driverInstanceId">Named in every rejection message, so a fleet-wide log identifies the row.</param>
    /// <returns>The typed options the driver is constructed with.</returns>
    /// <exception cref="InvalidOperationException">A knob is out of range, or the two deadlines are inverted.</exception>
    internal static SqlDriverOptions BuildOptions(SqlDriverConfigDto dto, string driverInstanceId)
    {
        ArgumentNullException.ThrowIfNull(dto);

        var defaultPollInterval = dto.DefaultPollInterval ?? TimeSpan.FromSeconds(5);
        var operationTimeout = dto.OperationTimeout ?? TimeSpan.FromSeconds(15);
        var commandTimeout = dto.CommandTimeout ?? TimeSpan.FromSeconds(10);
        var maxConcurrentGroups = dto.MaxConcurrentGroups ?? 4;

        RequirePositive(defaultPollInterval, "defaultPollInterval", driverInstanceId);
        RequirePositive(operationTimeout, "operationTimeout", driverInstanceId);
        RequirePositive(commandTimeout, "commandTimeout", driverInstanceId);
        if (maxConcurrentGroups < 1)
        {
            throw new InvalidOperationException(
                $"Sql driver config for '{driverInstanceId}' has maxConcurrentGroups {maxConcurrentGroups}; " +
                $"it must be at least 1.");
        }

        // Design §8.3. The two deadlines are independent and BOTH are required: commandTimeout is the
        // server-side backstop, operationTimeout the client-side wall-clock bound that alone survives a
        // wedged socket (the R2-01/STAB-14 lesson). Inverted — or equal — the client-side abort always fires
        // first and the backstop can never be reached, so a deployment quietly runs on one bound instead of
        // two. Nothing downstream enforces this: the reader stays deliberately usable with it inverted.
        if (operationTimeout <= commandTimeout)
        {
            throw new InvalidOperationException(
                $"Sql driver config for '{driverInstanceId}' has operationTimeout {operationTimeout} which " +
                $"is not greater than commandTimeout {commandTimeout}. The client-side operationTimeout must " +
                $"be strictly greater than the server-side commandTimeout backstop, or the backstop can " +
                $"never fire.");
        }

        return new SqlDriverOptions
        {
            RawTags = dto.RawTags is { Count: > 0 } rawTags ? [.. rawTags] : [],
            DefaultPollInterval = defaultPollInterval,
            OperationTimeout = operationTimeout,
            CommandTimeout = commandTimeout,
            MaxConcurrentGroups = maxConcurrentGroups,
            NullIsBad = dto.NullIsBad ?? false,
        };
    }

    /// <summary>Rejects a non-positive duration, naming the authored field.</summary>
    private static void RequirePositive(TimeSpan value, string field, string driverInstanceId)
    {
        if (value <= TimeSpan.Zero)
        {
            throw new InvalidOperationException(
                $"Sql driver config for '{driverInstanceId}' has {field} {value}; it must be greater than zero.");
        }
    }
}

/// <summary>
///     Everything <c>SqlDriver</c> derives from its <c>DriverConfig</c> JSON, returned as one value so a
///     reinitialize adopts all of it atomically.
///     <para>Partial adoption is the hazard this type exists to prevent: the dialect and the resolved
///     connection string are both config-derived, so adopting new <see cref="Options"/> alone would poll a
///     NEW tag set against the OLD database (Gitea #516).</para>
/// </summary>
/// <param name="Options">The typed driver options.</param>
/// <param name="Dialect">The provider seam the <c>provider</c> key selects.</param>
/// <param name="ConnectionString">The connection string resolved from <c>connectionStringRef</c>. Never logged.</param>
/// <param name="Dto">The raw parsed DTO, so a caller needing a key the options do not carry (e.g.
///     <c>allowWrites</c>, which the driver ignores but warns about) need not re-deserialize.</param>
public sealed record SqlDriverBinding(
    SqlDriverOptions Options,
    ISqlDialect Dialect,
    string ConnectionString,
    SqlDriverConfigDto Dto);
