using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.IdentityModel.Tokens;
using ZB.MOM.WW.Auth.AspNetCore;

namespace ZB.MOM.WW.ScadaBridge.Security;

public class JwtTokenService
{
    private readonly SecurityOptions _options;
    private readonly ILogger<JwtTokenService> _logger;

    // (Full canonical claims): these constants are the single source of
    // truth that every mint site, every authorization policy, and the token
    // validation parameters reference. Redefining them as aliases of the shared
    // ZbClaimTypes vocabulary migrates the whole module centrally:
    //   - Role  → the framework URI (ClaimTypes.Role) so [Authorize(Roles=…)],
    //             IsInRole, AND RequireClaim(RoleClaimType,…) all resolve.
    //   - SiteId → ZbClaimTypes.ScopeId ("zb:scopeid") — the canonical scope claim.
    //   - DisplayName/Username → the canonical "zb:" strings.
    // LastActivity has no canonical equivalent (it is a ScadaBridge-internal
    // idle-timeout anchor), so it keeps its existing literal.
    public const string DisplayNameClaimType = ZbClaimTypes.DisplayName;
    public const string UsernameClaimType = ZbClaimTypes.Username;
    public const string RoleClaimType = ZbClaimTypes.Role;
    public const string SiteIdClaimType = ZbClaimTypes.ScopeId;
    public const string LastActivityClaimType = "LastActivity";

    // The cookie session now stores the user's raw LDAP groups and a
    // role-mapping refresh anchor so an active interactive session can re-run the
    // DB-backed RoleMapper (NOT LDAP) mid-session and pick up central role-mapping
    // changes. These two have no canonical ZbClaimTypes equivalent (the shared
    // vocabulary covers identity/role/scope, not the ScadaBridge-internal refresh
    // machinery), so they keep "zb:"-prefixed ScadaBridge-local literals:
    //   - GroupClaimType ("zb:group", one per LDAP group) is the input the
    //     mid-session RoleMapper re-run consumes — the groups are the durable
    //     fact; the roles are the derived projection that can go stale.
    //   - LastRoleRefreshClaimType ("zb:lastrolerefresh", ISO-8601 "o") anchors
    //     the role-mapping refresh interval (SecurityOptions.RoleRefreshThresholdMinutes).
    // LastActivityClaimType (above) remains the idle-timeout anchor — a separate
    // clock from the role-refresh anchor.
    public const string GroupClaimType = "zb:group";
    public const string LastRoleRefreshClaimType = "zb:lastrolerefresh";

    /// <summary>
    /// Fixed issuer bound into every token and required on validation. Binding
    /// issuer/audience is defence-in-depth: even though the HMAC key is shared only
    /// between the two central nodes, accidental reuse of the same secret for an
    /// unrelated internal token would otherwise be silently exploitable.
    /// </summary>
    public const string TokenIssuer = "scadabridge-central";

    /// <summary>Fixed audience bound into every token and required on validation.</summary>
    public const string TokenAudience = "scadabridge-central";

    /// <summary>Initializes the service and validates the JWT signing key meets the minimum length requirement.</summary>
    /// <param name="options">Security options containing the JWT signing key, expiry, and timeout settings.</param>
    /// <param name="logger">Logger instance.</param>
    public JwtTokenService(IOptions<SecurityOptions> options, ILogger<JwtTokenService> logger)
    {
        _options = options?.Value ?? throw new ArgumentNullException(nameof(options));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));

        // Fail fast: a missing or short signing key produces trivially forgeable tokens.
        // HMAC-SHA256 requires a key of at least 256 bits (32 bytes).
        var keyByteLength = string.IsNullOrEmpty(_options.JwtSigningKey)
            ? 0
            : Encoding.UTF8.GetByteCount(_options.JwtSigningKey);
        if (keyByteLength < SecurityOptions.MinJwtSigningKeyBytes)
        {
            throw new InvalidOperationException(
                $"SecurityOptions.JwtSigningKey must be at least {SecurityOptions.MinJwtSigningKeyBytes} bytes " +
                $"(256 bits) for HMAC-SHA256; the configured key is {keyByteLength} byte(s). " +
                "Configure a strong signing key before starting the service.");
        }
    }

    /// <summary>
    /// Issues a fresh JWT. <paramref name="lastActivity"/> sets the idle-timeout
    /// anchor; when omitted (a brand-new login) it defaults to now. On a token
    /// refresh the caller MUST pass the existing anchor forward so the idle window
    /// continues to be measured from the user's last genuine activity rather than
    /// from token issuance time.
    /// </summary>
    /// <param name="displayName">Human-readable display name embedded as a claim.</param>
    /// <param name="username">Authenticated username embedded as a claim.</param>
    /// <param name="roles">Role names to embed as <see cref="RoleClaimType"/> claims.</param>
    /// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
    /// <param name="lastActivity">Idle-timeout anchor; defaults to now when null (fresh login).</param>
    /// <returns>The signed JWT string, ready to embed in a cookie or bearer header.</returns>
    public string GenerateToken(
        string displayName,
        string username,
        IReadOnlyList<string> roles,
        IReadOnlyList<string>? permittedSiteIds,
        DateTimeOffset? lastActivity = null)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
        var credentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var claims = new List<Claim>
        {
            new(DisplayNameClaimType, displayName),
            new(UsernameClaimType, username),
            new(LastActivityClaimType, (lastActivity ?? DateTimeOffset.UtcNow).ToString("o"))
        };

        foreach (var role in roles)
        {
            claims.Add(new Claim(RoleClaimType, role));
        }

        if (permittedSiteIds != null)
        {
            foreach (var siteId in permittedSiteIds)
            {
                claims.Add(new Claim(SiteIdClaimType, siteId));
            }
        }

        var token = new JwtSecurityToken(
            issuer: TokenIssuer,
            audience: TokenAudience,
            claims: claims,
            expires: DateTime.UtcNow.AddMinutes(_options.JwtExpiryMinutes),
            signingCredentials: credentials);

        // MapOutboundClaims=false: write the claim TYPE strings into the JWT
        // verbatim (e.g. the ClaimTypes.Role URI for RoleClaimType) rather than
        // letting JwtSecurityTokenHandler.DefaultOutboundClaimTypeMap rewrite the
        // framework URI to a short JWT name ("role"). Paired with
        // MapInboundClaims=false on validation (see ValidateToken), this makes the
        // claim type the policy checks — RequireClaim(RoleClaimType, …) — byte-for-byte
        // the same string both in the token and after it is read back, with no
        // mapping round-trip surprises. The "zb:" scope/display/username claims are
        // not in any map and were unaffected either way; pinning both directions
        // makes the role/site migration to canonical types deterministic.
        var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
        handler.OutboundClaimTypeMap.Clear();
        return handler.WriteToken(token);
    }

    /// <summary>Validates a JWT string and returns the decoded <see cref="ClaimsPrincipal"/>, or null if the token is invalid or expired.</summary>
    /// <param name="token">The JWT string to validate.</param>
    /// <returns>The decoded principal, or <c>null</c> if validation fails.</returns>
    public ClaimsPrincipal? ValidateToken(string token)
    {
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.JwtSigningKey));
        var validationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = TokenIssuer,
            ValidateAudience = true,
            ValidAudience = TokenAudience,
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = key,
            ClockSkew = TimeSpan.Zero,

            // The token was minted carrying the canonical claim TYPE strings
            // verbatim (RoleClaimType = ClaimTypes.Role URI, SiteIdClaimType =
            // ZbClaimTypes.ScopeId, etc.). Pin the same role/name claim types here
            // so the validated principal's Identity.Name and IsInRole resolve, and
            // so RequireClaim(RoleClaimType, …) sees exactly the type that is in the
            // token.
            RoleClaimType = RoleClaimType,
            NameClaimType = ZbClaimTypes.Name
        };

        try
        {
            // MapInboundClaims=false: do NOT let JwtSecurityTokenHandler rewrite
            // inbound claim types via DefaultInboundClaimTypeMap. The token already
            // holds the canonical type strings (see GenerateToken's
            // MapOutboundClaims=false), so reading them back unmapped yields exactly
            // the strings every policy + claim helper expects.
            var handler = new JwtSecurityTokenHandler { MapInboundClaims = false };
            var principal = handler.ValidateToken(token, validationParameters, out _);
            return principal;
        }
        catch (Exception ex) when (ex is SecurityTokenException or ArgumentException)
        {
            _logger.LogDebug(ex, "Token validation failed");
            return null;
        }
    }

    /// <summary>Returns true if the token carried by <paramref name="principal"/> is within the sliding-refresh threshold and should be reissued.</summary>
    /// <param name="principal">The current authenticated principal.</param>
    /// <returns><c>true</c> when the remaining token lifetime falls below <see cref="SecurityOptions.JwtRefreshThresholdMinutes"/>.</returns>
    public bool ShouldRefresh(ClaimsPrincipal principal)
    {
        var expClaim = principal.FindFirst("exp");
        if (expClaim == null || !long.TryParse(expClaim.Value, out var expUnix))
            return false;

        var expiry = DateTimeOffset.FromUnixTimeSeconds(expUnix);
        var remaining = expiry - DateTimeOffset.UtcNow;

        return remaining.TotalMinutes < _options.JwtRefreshThresholdMinutes;
    }

    /// <summary>Returns true if the session has exceeded the idle timeout based on the <see cref="LastActivityClaimType"/> claim.</summary>
    /// <param name="principal">The current authenticated principal.</param>
    /// <returns><c>true</c> when the elapsed time since last activity exceeds <see cref="SecurityOptions.IdleTimeoutMinutes"/>.</returns>
    public bool IsIdleTimedOut(ClaimsPrincipal principal)
    {
        var lastActivityClaim = principal.FindFirst(LastActivityClaimType);
        if (lastActivityClaim == null || !DateTimeOffset.TryParse(lastActivityClaim.Value, out var lastActivity))
            return true;

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

    /// <summary>
    /// Issues a fresh token (new expiry, re-queried roles) while <b>preserving</b> the
    /// existing <see cref="LastActivityClaimType"/> anchor. A refresh is itself
    /// triggered by a request, but it must not be treated as user activity — the
    /// idle window must keep being measured from the user's last genuine interaction,
    /// otherwise the documented 30-minute idle timeout could never fire for a client
    /// that polls in the background. Call <see cref="RecordActivity"/> to advance the
    /// anchor when handling a genuine user request.
    /// <para>
    /// A principal that is already past the idle timeout cannot be refreshed: this
    /// method returns <c>null</c> (the same "cannot refresh" signal it uses for missing
    /// claims). Enforcing the idle check here — rather than relying on the caller to
    /// invoke <see cref="IsIdleTimedOut"/> first — guarantees the documented 30-minute
    /// idle policy holds regardless of caller discipline; otherwise an idle-expired
    /// session could be kept alive indefinitely by background refreshes.
    /// </para>
    /// </summary>
    /// <param name="currentPrincipal">The current authenticated principal whose claims carry identity and the last-activity anchor.</param>
    /// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
    /// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
    /// <returns>A new token string, or <c>null</c> if the session is idle-expired or claims are missing.</returns>
    public string? RefreshToken(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
    {
        var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
        var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;

        if (displayName == null || username == null)
        {
            _logger.LogWarning("Cannot refresh token: missing DisplayName or Username claims");
            return null;
        }

        // An idle-expired session must not be renewed — the user must re-login.
        if (IsIdleTimedOut(currentPrincipal))
        {
            _logger.LogInformation(
                "Cannot refresh token for {Username}: session is past the idle timeout", username);
            return null;
        }

        return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
            ReadLastActivity(currentPrincipal));
    }

    /// <summary>
    /// Issues a fresh token whose <see cref="LastActivityClaimType"/> anchor is
    /// advanced to now. This is the explicit "user did something" path — distinct
    /// from <see cref="RefreshToken"/> — to be called by the request pipeline when
    /// handling a genuine user interaction.
    /// </summary>
    /// <param name="currentPrincipal">The current authenticated principal.</param>
    /// <param name="currentRoles">Re-queried role list to embed in the new token.</param>
    /// <param name="permittedSiteIds">Site identifiers the user may deploy to; null for system-wide access.</param>
    /// <returns>A new token string with an updated last-activity anchor, or <c>null</c> if claims are missing.</returns>
    public string? RecordActivity(ClaimsPrincipal currentPrincipal, IReadOnlyList<string> currentRoles, IReadOnlyList<string>? permittedSiteIds)
    {
        var displayName = currentPrincipal.FindFirst(DisplayNameClaimType)?.Value;
        var username = currentPrincipal.FindFirst(UsernameClaimType)?.Value;

        if (displayName == null || username == null)
        {
            _logger.LogWarning("Cannot record activity: missing DisplayName or Username claims");
            return null;
        }

        return GenerateToken(displayName, username, currentRoles, permittedSiteIds,
            DateTimeOffset.UtcNow);
    }

    private static DateTimeOffset? ReadLastActivity(ClaimsPrincipal principal)
    {
        var claim = principal.FindFirst(LastActivityClaimType);
        return claim != null && DateTimeOffset.TryParse(claim.Value, out var value)
            ? value
            : null;
    }
}
