using System.Security.Claims;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Roles;

namespace ZB.MOM.WW.ScadaBridge.Security;

/// <summary>
/// The outcome of a single cookie <c>OnValidatePrincipal</c> evaluation. The thin
/// <c>OnValidatePrincipal</c> lambda translates this into the matching
/// <c>CookieValidatePrincipalContext</c> calls (<c>RejectPrincipal</c> /
/// <c>ReplacePrincipal</c> + <c>ShouldRenew</c>); the decision itself is computed by
/// <see cref="CookieSessionValidator"/> so it is unit-testable in isolation.
/// </summary>
/// <param name="Action">What the caller must do with the principal.</param>
/// <param name="Principal">The replacement principal when <paramref name="Action"/> is <see cref="SessionValidationAction.Replace"/>; otherwise <c>null</c>.</param>
public readonly record struct SessionValidationResult(
    SessionValidationAction Action,
    ClaimsPrincipal? Principal)
{
    /// <summary>Keep the existing principal unchanged.</summary>
    public static SessionValidationResult Keep { get; } = new(SessionValidationAction.Keep, null);

    /// <summary>Reject the principal (idle-timed-out) — the caller signs the user out.</summary>
    public static SessionValidationResult Reject { get; } = new(SessionValidationAction.Reject, null);

    /// <summary>Replace the principal with a refreshed one and renew the cookie.</summary>
    /// <param name="principal">The rebuilt principal.</param>
    /// <returns>A replace result carrying <paramref name="principal"/>.</returns>
    public static SessionValidationResult Replace(ClaimsPrincipal principal) =>
        new(SessionValidationAction.Replace, principal);
}

/// <summary>The action a cookie session validation requires of the caller.</summary>
public enum SessionValidationAction
{
    /// <summary>Leave the principal as-is (no idle timeout, no refresh due, or a refresh error we swallow).</summary>
    Keep,

    /// <summary>The session is idle-timed-out; reject + sign out.</summary>
    Reject,

    /// <summary>The role mapping was refreshed; replace the principal and renew the cookie.</summary>
    Replace,
}

/// <summary>
/// The unit-testable core of the cookie <c>OnValidatePrincipal</c> event.
/// Enforces the idle timeout and refreshes the session's role/scope claims from the
/// STORED LDAP group claims via the DB-backed <see cref="RoleMapper"/> — <b>without any
/// LDAP call</b> — picking up central role-mapping (and scope-rule) changes mid-session.
/// </summary>
/// <remarks>
/// <para>
/// <b>Idle timeout</b> (default <see cref="SecurityOptions.IdleTimeoutMinutes"/> = 30):
/// computed from the <see cref="JwtTokenService.LastActivityClaimType"/> anchor. This is
/// the explicit, deterministic counterpart to the cookie middleware's
/// <c>ExpireTimeSpan</c> + <c>SlidingExpiration</c> window — both use the SAME idle
/// timeout value, so the explicit check never contradicts the cookie window. A
/// not-timed-out session has its last-activity anchor advanced to "now" (genuine
/// request = activity), mirroring the sliding renew.
/// </para>
/// <para>
/// <b>Role refresh</b> (default <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>
/// = 15): when the elapsed time since <see cref="JwtTokenService.LastRoleRefreshClaimType"/>
/// exceeds the threshold, the stored groups are re-mapped and the principal is rebuilt via
/// <see cref="SessionClaimBuilder"/> (identical shape to <c>/auth/login</c>). If the DB
/// mapping revoked the user's roles, the rebuilt principal reflects the loss.
/// </para>
/// <para>
/// <b>Failure policy</b>: a refresh error (e.g. the mapper throws because the DB is
/// unreachable) NEVER signs the user out and NEVER throws out of validation — it returns
/// <see cref="SessionValidationResult.Keep"/>, mirroring the documented "LDAP failure:
/// active sessions continue with current roles" stance. Only the explicit idle-timeout
/// path rejects.
/// </para>
/// </remarks>
public sealed class CookieSessionValidator
{
    private readonly IGroupRoleMapper<string> _roleMapper;
    private readonly SecurityOptions _options;
    private readonly TimeProvider _timeProvider;
    private readonly ILogger<CookieSessionValidator> _logger;

    /// <summary>Initializes the validator.</summary>
    /// <param name="roleMapper">The DB-backed group→role mapping seam (no LDAP) used for the mid-session refresh.</param>
    /// <param name="options">Security options carrying the idle and role-refresh thresholds.</param>
    /// <param name="timeProvider">Clock source; injected so tests can advance time deterministically.</param>
    /// <param name="logger">Logger instance.</param>
    public CookieSessionValidator(
        IGroupRoleMapper<string> roleMapper,
        IOptions<SecurityOptions> options,
        TimeProvider timeProvider,
        ILogger<CookieSessionValidator> logger)
    {
        _roleMapper = roleMapper ?? throw new ArgumentNullException(nameof(roleMapper));
        _options = (options ?? throw new ArgumentNullException(nameof(options))).Value;
        _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    /// <summary>
    /// Evaluates a cookie principal: enforces the idle timeout, then refreshes the
    /// role/scope claims from the stored LDAP groups when the role-refresh interval has
    /// elapsed. Never throws.
    /// </summary>
    /// <param name="principal">The current cookie principal under validation.</param>
    /// <param name="ct">Cancellation token (the request-aborted token in the pipeline).</param>
    /// <returns>The action the caller must take and any replacement principal.</returns>
    public async Task<SessionValidationResult> ValidateAsync(ClaimsPrincipal? principal, CancellationToken ct = default)
    {
        // An unauthenticated / null principal is left to the rest of the pipeline.
        if (principal?.Identity is not { IsAuthenticated: true })
        {
            return SessionValidationResult.Keep;
        }

        var now = _timeProvider.GetUtcNow();

        // 1) Idle-timeout enforcement — the only path that rejects. A missing/unparsable
        //    last-activity anchor is treated as timed-out (fail-closed): a session we
        //    cannot age must not be kept alive forever.
        if (IsIdleTimedOut(principal, now))
        {
            _logger.LogInformation(
                "Cookie session for {Username} rejected: past the {IdleTimeout}-minute idle timeout.",
                principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "(unknown)",
                _options.IdleTimeoutMinutes);
            return SessionValidationResult.Reject;
        }

        // 2) Role-mapping refresh — best-effort. Any failure keeps the existing session.
        try
        {
            var refreshed = await TryRefreshAsync(principal, now, ct).ConfigureAwait(false);
            if (refreshed is not null)
            {
                return SessionValidationResult.Replace(refreshed);
            }
        }
        catch (Exception ex)
        {
            // SECURITY: never broaden access and never sign the user out on a transient
            // refresh fault — keep the existing principal (current roles) and swallow.
            _logger.LogWarning(
                ex,
                "Mid-session role refresh failed for {Username}; keeping existing session and roles.",
                principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value ?? "(unknown)");
            return SessionValidationResult.Keep;
        }

        return SessionValidationResult.Keep;
    }

    /// <summary>
    /// Returns true when the session's last-activity anchor is older than
    /// <see cref="SecurityOptions.IdleTimeoutMinutes"/>. A missing/unparsable anchor is
    /// treated as timed-out (fail-closed).
    /// </summary>
    /// <param name="principal">The cookie principal.</param>
    /// <param name="now">The current instant.</param>
    /// <returns><c>true</c> if the session has exceeded the idle window.</returns>
    public bool IsIdleTimedOut(ClaimsPrincipal principal, DateTimeOffset now)
    {
        var claim = principal.FindFirst(JwtTokenService.LastActivityClaimType);
        if (claim is null || !DateTimeOffset.TryParse(claim.Value, out var lastActivity))
        {
            return true;
        }

        return (now - lastActivity).TotalMinutes > _options.IdleTimeoutMinutes;
    }

    // Returns a rebuilt principal when the role-refresh interval has elapsed; null when
    // nothing changed. The principal is rebuilt via SessionClaimBuilder so its shape is
    // identical to /auth/login.
    private async Task<ClaimsPrincipal?> TryRefreshAsync(ClaimsPrincipal principal, DateTimeOffset now, CancellationToken ct)
    {
        var roleRefreshDue = IsRoleRefreshDue(principal, now);
        if (!roleRefreshDue)
        {
            // No mapping refresh due. We deliberately do NOT mint a new principal just to
            // advance LastActivity: the cookie middleware's SlidingExpiration already
            // renews the cookie window on activity, so the idle anchor only needs
            // advancing when we are rebuilding the principal anyway (on a role refresh).
            // This keeps the no-op request path allocation-free and avoids a cookie
            // re-issue on every request.
            return null;
        }

        var username = principal.FindFirst(JwtTokenService.UsernameClaimType)?.Value;
        var displayName = principal.FindFirst(JwtTokenService.DisplayNameClaimType)?.Value;
        if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(displayName))
        {
            // Malformed principal — cannot rebuild faithfully. Keep it (do not reject).
            _logger.LogWarning("Cannot refresh role mapping: principal is missing username/display-name claims.");
            return null;
        }

        var groups = SessionClaimBuilder.ReadGroups(principal);

        // Re-run the DB-backed mapping on the STORED groups — NO LDAP call.
        var mapping = await _roleMapper.MapAsync(groups, ct).ConfigureAwait(false);
        var scope = mapping.Scope is RoleMappingResult mapped
            ? mapped
            : new RoleMappingResult(mapping.Roles, [], IsSystemWideDeployment: false);

        // Rebuild identically to /auth/login, advancing BOTH anchors: the role-refresh
        // anchor (we just refreshed) and the idle anchor (this is a genuine request).
        return SessionClaimBuilder.Build(username, displayName, groups, scope, now);
    }

    /// <summary>
    /// Returns true when the elapsed time since the last role refresh exceeds
    /// <see cref="SecurityOptions.RoleRefreshThresholdMinutes"/>. A missing/unparsable
    /// anchor is treated as due (refresh now and re-stamp the anchor).
    /// </summary>
    /// <param name="principal">The cookie principal.</param>
    /// <param name="now">The current instant.</param>
    /// <returns><c>true</c> if a role-mapping refresh is due.</returns>
    public bool IsRoleRefreshDue(ClaimsPrincipal principal, DateTimeOffset now)
    {
        var claim = principal.FindFirst(JwtTokenService.LastRoleRefreshClaimType);
        if (claim is null || !DateTimeOffset.TryParse(claim.Value, out var lastRefresh))
        {
            return true;
        }

        return (now - lastRefresh).TotalMinutes > _options.RoleRefreshThresholdMinutes;
    }
}
