namespace ZB.MOM.WW.Secrets.Replicator.SqlServer;

/// <summary>
/// Configuration for the shared SQL-Server secret store and its hub replication. Bound from a
/// configuration section by the <c>AddZbSecretsSqlServer*</c> extensions.
/// </summary>
public sealed class SqlServerSecretsOptions
{
    private string _schemaName = SqlServerSecretsSchema.DefaultSchemaName;

    /// <summary>
    /// Connection string for the shared secret database. Required. Supply it through a
    /// <c>${secret:}</c> / <c>secret:</c> reference like every other connection string in the family
    /// — the shared store holds ciphertext only, but its credentials are still credentials.
    /// </summary>
    public string ConnectionString { get; set; } = string.Empty;

    /// <summary>
    /// SQL schema the secret tables live in. Defaults to <c>zbsecrets</c> so the secret tables never
    /// collide with an application's own <c>dbo</c> objects when they share a database.
    /// </summary>
    /// <exception cref="ArgumentException">
    /// The value is not a plain identifier (letters, digits, and underscore, not starting with a
    /// digit, at most 128 characters).
    /// </exception>
    /// <remarks>
    /// Validated on assignment rather than at use: the schema name is interpolated into DDL and
    /// query text (T-SQL cannot parameterize an object name), so the allow-list is the injection
    /// boundary and it must be impossible to get an unvalidated value into the property at all.
    /// </remarks>
    public string SchemaName
    {
        get => _schemaName;
        set => _schemaName = ValidateIdentifier(value);
    }

    /// <summary>
    /// How often the sync service reconciles the local store against the hub, in hub-replication
    /// mode. Defaults to 30 seconds. Ignored in shared-store mode (there is nothing to reconcile).
    /// </summary>
    public TimeSpan SyncInterval { get; set; } = TimeSpan.FromSeconds(30);

    /// <summary>
    /// When <see langword="true"/> (the default), hub-replication mode runs a reconcile at startup
    /// before the first interval elapses, so a node that was down while a peer wrote does not serve
    /// stale secrets for a whole interval after it comes back.
    /// </summary>
    public bool SyncOnStartup { get; set; } = true;

    /// <summary>
    /// Command timeout applied to every store operation. Defaults to 30 seconds — long enough for a
    /// contended shared database, short enough that a hung hub surfaces as an error rather than
    /// stalling a resolve indefinitely.
    /// </summary>
    public TimeSpan CommandTimeout { get; set; } = TimeSpan.FromSeconds(30);

    /// <summary>
    /// Throws if the options are unusable. Called by the DI extensions at registration time so a
    /// misconfigured node fails at startup rather than on the first secret resolve.
    /// </summary>
    /// <exception cref="InvalidOperationException">The connection string is missing, or an interval is not positive.</exception>
    public void Validate()
    {
        if (string.IsNullOrWhiteSpace(ConnectionString))
        {
            throw new InvalidOperationException(
                "SQL-Server secret store requires a ConnectionString (Secrets:SqlServer:ConnectionString).");
        }

        if (SyncInterval <= TimeSpan.Zero)
        {
            throw new InvalidOperationException(
                $"SQL-Server secret replication SyncInterval must be positive (was {SyncInterval}).");
        }

        if (CommandTimeout <= TimeSpan.Zero)
        {
            throw new InvalidOperationException(
                $"SQL-Server secret store CommandTimeout must be positive (was {CommandTimeout}).");
        }
    }

    /// <summary>
    /// Strict allow-list for a SQL identifier that will be interpolated into DDL or query text.
    /// Rejects everything that is not <c>[A-Za-z_][A-Za-z0-9_]{0,127}</c> — no brackets, quotes,
    /// dots, or spaces — so no bracket-escape (<c>]]</c>) trick can break out of the quoting applied
    /// at the use site. Uses the ASCII-only character tests deliberately, so homoglyph and
    /// full-width look-alikes are rejected rather than accepted as "letters".
    /// </summary>
    /// <param name="value">The candidate identifier.</param>
    /// <returns>The same value, once validated.</returns>
    /// <exception cref="ArgumentException">The value is not a plain identifier.</exception>
    internal static string ValidateIdentifier(string value)
    {
        ArgumentException.ThrowIfNullOrWhiteSpace(value);

        if (value.Length > 128)
        {
            throw new ArgumentException(
                $"SQL schema name must be at most 128 characters (was {value.Length}).", nameof(value));
        }

        if (!char.IsAsciiLetter(value[0]) && value[0] != '_')
        {
            throw new ArgumentException(
                $"SQL schema name must start with a letter or underscore (was '{value}').", nameof(value));
        }

        foreach (char c in value)
        {
            if (!char.IsAsciiLetterOrDigit(c) && c != '_')
            {
                throw new ArgumentException(
                    $"SQL schema name may contain only letters, digits, and underscore (was '{value}').",
                    nameof(value));
            }
        }

        return value;
    }
}
