namespace ZB.MOM.WW.ScadaBridge.Security;

/// <summary>
/// Non-LDAP security configuration for the ScadaBridge Central UI.
/// </summary>
/// <remarks>
/// <para>
/// <b>JWT Bearer path (<c>/auth/token</c>)</b>: <see cref="JwtSigningKey"/> and
/// <see cref="JwtExpiryMinutes"/> govern the short-lived Bearer token issued to
/// the CLI / Inbound API. They have no effect on the Blazor cookie session.
/// </para>
/// <para>
/// <b>Blazor cookie session</b>: <see cref="IdleTimeoutMinutes"/> and
/// <see cref="RoleRefreshThresholdMinutes"/> govern the cookie-only session used by
/// the Blazor Server UI. There is no embedded JWT in this path — the cookie is
/// HttpOnly/Secure and managed entirely by ASP.NET Core cookie authentication.
/// </para>
/// <para>
/// The LDAP connection settings that used to live here as
/// flat <c>Ldap*</c> keys (server, port, transport, search base, service account,
/// attributes, timeout) moved into a nested <c>ScadaBridge:Security:Ldap</c>
/// sub-section bound to the shared <c>ZB.MOM.WW.Auth.Abstractions.Ldap.LdapOptions</c>
/// and registered via <c>AddZbLdapAuth</c>. This is a BREAKING config-key change —
/// see CHANGELOG. The non-LDAP fields below are unchanged and still bound from
/// <c>ScadaBridge:Security</c>.
/// </para>
/// </remarks>
public class SecurityOptions
{
    /// <summary>
    /// Symmetric HMAC-SHA256 signing key for cookie-embedded JWTs. Must be at least
    /// 32 bytes (256 bits) — validated at <see cref="JwtTokenService"/> construction.
    /// </summary>
    public string JwtSigningKey { get; set; } = string.Empty;

    /// <summary>
    /// Minimum signing-key length in bytes required for HMAC-SHA256 (256 bits).
    /// </summary>
    public const int MinJwtSigningKeyBytes = 32;
    /// <summary>Cookie-embedded JWT lifetime in minutes before it must be refreshed.</summary>
    public int JwtExpiryMinutes { get; set; } = 15;
    /// <summary>
    /// Session idle timeout in minutes for the Blazor cookie session; sessions inactive
    /// beyond this are expired and the user is redirected to <c>/login</c>. Default: <b>30</b>.
    /// </summary>
    /// <remarks>
    /// Because <see cref="RoleRefreshThresholdMinutes"/> is the only operation that advances
    /// the <c>LastActivity</c> anchor, the effective maximum idle window before a session is
    /// guaranteed to be rejected is approximately
    /// <c>IdleTimeoutMinutes + RoleRefreshThresholdMinutes</c> (~45 minutes with defaults).
    /// This is intentional and mirrors the cookie middleware's own <c>SlidingExpiration</c>
    /// fuzziness. Must be strictly greater than <see cref="RoleRefreshThresholdMinutes"/>
    /// (enforced at startup by <see cref="SecurityOptionsValidator"/>).
    /// </remarks>
    public int IdleTimeoutMinutes { get; set; } = 30;

    /// <summary>
    /// Minutes before token expiry to trigger refresh.
    /// </summary>
    public int JwtRefreshThresholdMinutes { get; set; } = 5;

    /// <summary>
    /// How long a cookie session's role-mapping projection may be stale
    /// before <c>OnValidatePrincipal</c> re-runs the DB-backed <c>RoleMapper</c> on the
    /// session's stored LDAP group claims and rebuilds the role/scope claims. Default:
    /// <b>15 minutes</b>, matching the documented sliding-refresh cadence.
    /// </summary>
    /// <remarks>
    /// This is a purely central (database) refresh — it picks up LDAP-group→role mapping
    /// changes and scope-rule changes WITHOUT contacting LDAP, so revoked roles take effect
    /// within this window. It does NOT pick up live LDAP group-membership changes (the
    /// shared LDAP library exposes no passwordless group-search; that remains a
    /// next-login refresh — see Component-Security.md).
    /// <para>
    /// Because a role-refresh is also the only operation that advances the
    /// <c>LastActivity</c> anchor, the effective maximum idle window is approximately
    /// <c><see cref="IdleTimeoutMinutes"/> + RoleRefreshThresholdMinutes</c> (~45 minutes
    /// with defaults). Must be strictly less than <see cref="IdleTimeoutMinutes"/>
    /// (enforced at startup by <see cref="SecurityOptionsValidator"/>).
    /// </para>
    /// </remarks>
    public int RoleRefreshThresholdMinutes { get; set; } = 15;

    /// <summary>
    /// When true (default) the authentication cookie is always marked
    /// <c>Secure</c> (sent only over HTTPS) — the correct production setting,
    /// since the cookie carries the embedded JWT bearer credential. Set false
    /// for an HTTP-only deployment such as the local Docker dev cluster: the
    /// cookie then uses <c>SameAsRequest</c>, so it is still <c>Secure</c> on
    /// any HTTPS request but is usable over plain HTTP.
    /// </summary>
    public bool RequireHttpsCookie { get; set; } = true;

    /// <summary>The canonical default authentication-cookie name (<see cref="CookieName"/>).</summary>
    public const string DefaultCookieName = "ZB.MOM.WW.ScadaBridge.Auth";

    /// <summary>
    /// Authentication cookie name. Defaults to <see cref="DefaultCookieName"/>. Override it
    /// (<c>ScadaBridge:Security:CookieName</c>) to give a distinct name to a deployment that
    /// shares a hostname with another ScadaBridge environment — browser cookies are scoped by
    /// host+path but NOT by port, so two clusters on the same host (e.g. two local Docker
    /// stacks on <c>localhost:9000</c> and <c>localhost:9100</c>) would otherwise clobber each
    /// other's session under a shared cookie name. A blank/whitespace value falls back to
    /// <see cref="DefaultCookieName"/>. Changing this invalidates existing sessions on next deploy.
    /// </summary>
    public string CookieName { get; set; } = DefaultCookieName;

    /// <summary>
    /// Maximum failed LDAP binds tolerated per <see cref="LoginThrottle"/> fixed window for a
    /// single <c>{username}|{ip}</c> pair before the pair is locked out. Default: <b>5</b>.
    /// Set to <b>0</b> to disable login throttling entirely.
    /// </summary>
    public int MaxLoginFailuresPerWindow { get; set; } = 5;

    /// <summary>
    /// The <see cref="LoginThrottle"/> fixed-window length in minutes. Failures older than this
    /// (measured from the window's first failure) no longer count toward the lockout threshold.
    /// Default: <b>5</b>.
    /// </summary>
    public int LoginFailureWindowMinutes { get; set; } = 5;

    /// <summary>
    /// How long, in minutes, a <c>{username}|{ip}</c> pair stays locked out once it reaches
    /// <see cref="MaxLoginFailuresPerWindow"/> failures within the window. Default: <b>5</b>.
    /// </summary>
    public int LoginLockoutMinutes { get; set; } = 5;
}

/// <summary>
/// Startup validator for <see cref="SecurityOptions"/>. Fails fast at boot
/// on any configuration that would defeat idle-timeout enforcement.
/// </summary>
/// <remarks>
/// Registered with <c>ValidateOnStart()</c> by
/// <see cref="ServiceCollectionExtensions.AddSecurity"/> so a misconfigured appsettings
/// section is caught at application startup rather than silently misapplied at runtime.
/// </remarks>
public sealed class SecurityOptionsValidator : Microsoft.Extensions.Options.IValidateOptions<SecurityOptions>
{
    /// <summary>Validates the bound <see cref="SecurityOptions"/> at application startup, failing fast on invalid combinations.</summary>
    /// <param name="name">The name of the options instance being validated, or <c>null</c> for the default instance.</param>
    /// <param name="options">The <see cref="SecurityOptions"/> values to validate.</param>
    /// <returns>A success result when the options are valid, otherwise a failure result describing the invalid setting.</returns>
    public Microsoft.Extensions.Options.ValidateOptionsResult Validate(string? name, SecurityOptions options)
    {
        // SECURITY: RoleRefreshThresholdMinutes must be strictly less than IdleTimeoutMinutes.
        // The role-refresh cycle is the ONLY operation that advances the LastActivity anchor,
        // so a single un-refreshed cycle must not be able to exhaust the entire idle window.
        // If threshold >= idle, a user who triggers exactly one refresh at t=0 would have
        // their anchor advanced to t=threshold while the idle check only fires at t>idle —
        // meaning t=threshold >= t=idle is already past (or at) the expiry, defeating enforcement.
        if (options.RoleRefreshThresholdMinutes >= options.IdleTimeoutMinutes)
        {
            return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
                $"{nameof(SecurityOptions.RoleRefreshThresholdMinutes)} " +
                $"({options.RoleRefreshThresholdMinutes}) must be strictly less than " +
                $"{nameof(SecurityOptions.IdleTimeoutMinutes)} " +
                $"({options.IdleTimeoutMinutes}). " +
                $"A single refresh cycle must not equal or exceed the idle window or idle " +
                $"enforcement is defeated.");
        }

        // SECURITY: login-throttle thresholds must be non-negative. 0 for
        // MaxLoginFailuresPerWindow is the documented "disable throttling" escape hatch;
        // a negative value (or a negative window/lockout) is a misconfiguration that would
        // make the fixed-window math ill-defined, so it fails fast at boot.
        if (options.MaxLoginFailuresPerWindow < 0)
        {
            return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
                $"{nameof(SecurityOptions.MaxLoginFailuresPerWindow)} " +
                $"({options.MaxLoginFailuresPerWindow}) must be non-negative " +
                $"(0 disables login throttling).");
        }

        if (options.LoginFailureWindowMinutes < 0)
        {
            return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
                $"{nameof(SecurityOptions.LoginFailureWindowMinutes)} " +
                $"({options.LoginFailureWindowMinutes}) must be non-negative.");
        }

        if (options.LoginLockoutMinutes < 0)
        {
            return Microsoft.Extensions.Options.ValidateOptionsResult.Fail(
                $"{nameof(SecurityOptions.LoginLockoutMinutes)} " +
                $"({options.LoginLockoutMinutes}) must be non-negative.");
        }

        return Microsoft.Extensions.Options.ValidateOptionsResult.Success;
    }
}
