/*
 * Copyright 2025 Hirokazu Kobayashi
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.idp.server.core.openid.oauth.configuration;

import java.time.Duration;
import java.util.*;
import java.util.ArrayList;
import java.util.List;
import org.idp.server.core.openid.authentication.AuthenticationInteractionType;
import org.idp.server.platform.json.JsonReadable;

public class AuthorizationServerExtensionConfiguration implements JsonReadable {

  List<String> fapiBaselineScopes = new ArrayList<>();
  List<String> fapiAdvanceScopes = new ArrayList<>();
  List<String> fapi20Scopes = new ArrayList<>();
  List<String> requiredIdentityVerificationScopes = new ArrayList<>();

  /** opaque: identifier type JWT: consisting type */
  String accessTokenType = "opaque";

  int authorizationCodeValidDuration = 600;

  /**
   * The audience of an access token when the granted scopes point at no configured resource.
   *
   * <p>RFC 9068 requires an audience on every JWT access token, so this is what a token carries
   * when {@code scope_resource_mapping} matches nothing. Left empty, the issuer stands in, which
   * keeps tokens conformant on a deployment that has not modelled its resources yet.
   */
  String defaultResourceIndicator = "";

  /**
   * Resource indicator to the scopes that belong to it, e.g. {@code {"https://api": ["account"]}}.
   */
  Map<String, List<String>> scopeResourceMapping = new HashMap<>();

  String tokenSignedKeyId = "";
  String idTokenSignedKeyId = "";
  long accessTokenDuration = 1800;
  long refreshTokenDuration = 3600;
  String refreshTokenStrategy = "FIXED";
  boolean rotateRefreshToken = true;
  long idTokenDuration = 3600;
  boolean idTokenStrictMode = false;
  long defaultMaxAge = 86400;
  long authorizationResponseDuration = 60;
  int backchannelAuthenticationRequestExpiresIn = 300;
  int backchannelAuthenticationPollingInterval = 5;
  boolean clientAttestationChallengeRequired = false;
  int clientAttestationChallengeDuration = 300;
  // Issue #1893: how far iat may be from now, in seconds, before the JWT is refused.
  int dpopProofAcceptableWindowSeconds = 60;
  int clientAttestationPopAcceptableWindowSeconds = 60;
  // Issue #1902: how long after its iat (or from now, without iat) a client assertion is accepted.
  int clientAssertionMaxLifetimeSeconds = 60;

  /**
   * Issue #1893: the range the iat windows accept. Zero or less would refuse every proof; a long
   * window would hardly limit reuse, and keeps each jti that much longer.
   */
  public static final int MIN_ACCEPTABLE_WINDOW_SECONDS = 1;

  public static final int MAX_ACCEPTABLE_WINDOW_SECONDS = 600;
  boolean requiredBackchannelAuthUserCode = false;
  String backchannelAuthUserCodeType = "password";
  String defaultCibaAuthenticationInteractionType = "authentication-device-notification";
  int oauthAuthorizationRequestExpiresIn = 1800;
  int pushedAuthorizationRequestExpiresIn = 90;
  boolean customClaimsScopeMapping = false;
  boolean accessTokenSelectiveUserCustomProperties = false;
  boolean accessTokenSelectiveVerifiedClaims = false;
  boolean accessTokenSelectiveStandardClaims = false;
  boolean redirectUriExactMatchRequired = false;

  public AuthorizationServerExtensionConfiguration() {}

  public boolean hasFapiBaselineScope(Set<String> scopes) {
    return scopes.stream().anyMatch(scope -> fapiBaselineScopes.contains(scope));
  }

  public boolean hasFapiAdvanceScope(Set<String> scopes) {
    return scopes.stream().anyMatch(scope -> fapiAdvanceScopes.contains(scope));
  }

  public boolean hasFapi20Scope(Set<String> scopes) {
    return scopes.stream().anyMatch(scope -> fapi20Scopes.contains(scope));
  }

  public boolean hasRequiredIdentityVerificationScope(Set<String> scopes) {
    return scopes.stream().anyMatch(scope -> requiredIdentityVerificationScopes.contains(scope));
  }

  public String accessTokenType() {
    return accessTokenType;
  }

  public boolean isIdentifierAccessTokenType() {
    return accessTokenType.equals("opaque");
  }

  public int authorizationCodeValidDuration() {
    return authorizationCodeValidDuration;
  }

  public String defaultResourceIndicator() {
    return defaultResourceIndicator;
  }

  public boolean hasDefaultResourceIndicator() {
    return defaultResourceIndicator != null && !defaultResourceIndicator.isEmpty();
  }

  /**
   * The scopes that belong to each resource.
   *
   * <p>Never null: callers filter this map rather than check it, and a stored configuration
   * carrying an explicit null would otherwise fail every authorization request for the tenant
   * rather than behave as though no resources were modelled.
   */
  public Map<String, List<String>> scopeResourceMapping() {
    if (scopeResourceMapping == null) {
      return Map.of();
    }
    return scopeResourceMapping;
  }

  public String tokenSignedKeyId() {
    return tokenSignedKeyId;
  }

  public String idTokenSignedKeyId() {
    return idTokenSignedKeyId;
  }

  public long accessTokenDuration() {
    return accessTokenDuration;
  }

  public long refreshTokenDuration() {
    return refreshTokenDuration;
  }

  public RefreshTokenStrategy refreshTokenStrategy() {
    return RefreshTokenStrategy.of(refreshTokenStrategy);
  }

  public boolean isFixedRefreshTokenStrategy() {
    return refreshTokenStrategy().isFixed();
  }

  public boolean isExtendsRefreshTokenStrategy() {
    return refreshTokenStrategy().isExtends();
  }

  /**
   * Whether the AS rotates the refresh token value on each refresh.
   *
   * <p>Defaults to {@code true} (rotate). FAPI 2.0 Security Profile §5.3.2.1-9 prohibits rotation
   * "except in extraordinary circumstances", so FAPI 2.0 tenants must override this to {@code
   * false} in their configuration.
   */
  public boolean isRotateRefreshToken() {
    return rotateRefreshToken;
  }

  public long idTokenDuration() {
    return idTokenDuration;
  }

  public boolean isIdTokenStrictMode() {
    return idTokenStrictMode;
  }

  public long defaultMaxAge() {
    return defaultMaxAge;
  }

  public long authorizationResponseDuration() {
    return authorizationResponseDuration;
  }

  public int backchannelAuthenticationRequestExpiresIn() {
    return backchannelAuthenticationRequestExpiresIn;
  }

  public int backchannelAuthenticationPollingInterval() {
    return backchannelAuthenticationPollingInterval;
  }

  /**
   * Whether the {@code challenge} claim of the Client Attestation PoP JWT is enforced
   * (draft-ietf-oauth-attestation-based-client-auth-11 Section 7.2 item 5).
   *
   * <p>Kept separate from advertising {@code challenge_endpoint} so a tenant can publish the
   * endpoint first, let its clients start fetching challenges, and only then start rejecting
   * requests that carry none.
   */
  public boolean clientAttestationChallengeRequired() {
    return clientAttestationChallengeRequired;
  }

  /**
   * Lifetime of an issued Challenge. Defaults to 300 seconds, matching {@code
   * backchannel_authentication_request_expires_in} so that one challenge covers a whole CIBA
   * polling cycle; a challenge stays valid for its whole lifetime by design.
   */
  public int clientAttestationChallengeDuration() {
    return clientAttestationChallengeDuration;
  }

  /**
   * Issue #1893: how far a DPoP proof's {@code iat} may be from now, either way (RFC 9449 Section
   * 4.3 check 11, Section 11.1). Also how long its {@code jti} is remembered.
   */
  public Duration dpopProofAcceptableWindow() {
    return Duration.ofSeconds(dpopProofAcceptableWindowSeconds);
  }

  /**
   * Issue #1893: how far a Client Attestation PoP JWT's {@code iat} may be from now, either way.
   * Also how long its {@code jti} is remembered.
   */
  public Duration clientAttestationPopAcceptableWindow() {
    return Duration.ofSeconds(clientAttestationPopAcceptableWindowSeconds);
  }

  /**
   * Issue #1902: the longest a client assertion is accepted for (RFC 7523 Section 3, requirement 4:
   * "the authorization server may reject JWTs with an "exp" claim value that is unreasonably far in
   * the future"). With an {@code iat}, it is accepted until {@code iat} plus this; without one, its
   * {@code exp} may be at most this far ahead. Also bounds how long its {@code jti} is remembered.
   */
  public Duration clientAssertionMaxLifetime() {
    return Duration.ofSeconds(clientAssertionMaxLifetimeSeconds);
  }

  /**
   * @return the iat windows that are out of range ({@link #MIN_ACCEPTABLE_WINDOW_SECONDS} to {@link
   *     #MAX_ACCEPTABLE_WINDOW_SECONDS}); empty when both are in range
   */
  public List<String> acceptableWindowViolations() {
    List<String> violations = new ArrayList<>();
    addIfOutOfRange(
        violations, "dpop_proof_acceptable_window_seconds", dpopProofAcceptableWindowSeconds);
    addIfOutOfRange(
        violations,
        "client_attestation_pop_acceptable_window_seconds",
        clientAttestationPopAcceptableWindowSeconds);
    addIfOutOfRange(
        violations, "client_assertion_max_lifetime_seconds", clientAssertionMaxLifetimeSeconds);
    return violations;
  }

  private static void addIfOutOfRange(List<String> violations, String name, int seconds) {
    if (seconds < MIN_ACCEPTABLE_WINDOW_SECONDS || seconds > MAX_ACCEPTABLE_WINDOW_SECONDS) {
      violations.add(
          String.format(
              "%s must be between %d and %d seconds, but was %d",
              name, MIN_ACCEPTABLE_WINDOW_SECONDS, MAX_ACCEPTABLE_WINDOW_SECONDS, seconds));
    }
  }

  public boolean requiredBackchannelAuthUserCode() {
    return requiredBackchannelAuthUserCode;
  }

  public String backchannelAuthUserCodeType() {
    return backchannelAuthUserCodeType;
  }

  public int oauthAuthorizationRequestExpiresIn() {
    return oauthAuthorizationRequestExpiresIn;
  }

  public int pushedAuthorizationRequestExpiresIn() {
    return pushedAuthorizationRequestExpiresIn;
  }

  public List<String> fapiBaselineScopes() {
    return fapiBaselineScopes;
  }

  public List<String> fapi20Scopes() {
    return fapi20Scopes;
  }

  public List<String> fapiAdvanceScopes() {
    return fapiAdvanceScopes;
  }

  public List<String> requiredIdentityVerificationScopes() {
    return requiredIdentityVerificationScopes;
  }

  public boolean idTokenStrictMode() {
    return idTokenStrictMode;
  }

  public boolean enabledCustomClaimsScopeMapping() {
    return customClaimsScopeMapping;
  }

  public boolean enabledAccessTokenSelectiveUserCustomProperties() {
    return accessTokenSelectiveUserCustomProperties;
  }

  public boolean enabledAccessTokenSelectiveVerifiedClaims() {
    return accessTokenSelectiveVerifiedClaims;
  }

  public boolean enabledAccessTokenSelectiveStandardClaims() {
    return accessTokenSelectiveStandardClaims;
  }

  /**
   * RFC 9700 Section 2.1: exact string matching for redirect_uri.
   *
   * <p>RFC 6749 Section 3.1.2.3 lets the server compare against registered URIs "as defined in
   * [RFC3986] Section 6", which admits syntax-based normalization. RFC 9700 (BCP 240, updating
   * 6749) narrowed that to exact string matching, keeping only the loopback port exception for
   * native apps. This turns the OAuth 2.0 profile onto the narrower rule; OIDC and FAPI already
   * compare exactly.
   *
   * <p>Default off: a failed comparison is not redirected back to the client (RFC 6749 Section
   * 3.1.2.4), so tightening it silently strands end users on an error page rather than returning
   * {@code invalid_request} to the client. See the v0.13.0 impact ledger for the migration.
   *
   * @return true when redirect_uri must match a registered URI byte for byte
   */
  public boolean isRedirectUriExactMatchRequired() {
    return redirectUriExactMatchRequired;
  }

  public AuthenticationInteractionType defaultCibaAuthenticationInteractionType() {
    return new AuthenticationInteractionType(defaultCibaAuthenticationInteractionType);
  }

  /**
   * The management update is a full replacement, so anything missing here is dropped by a GET -&gt;
   * modify -&gt; PUT round trip, and the diff cannot show it either. See #1845.
   */
  public Map<String, Object> toMap() {
    Map<String, Object> map = new HashMap<>();
    map.put("access_token_type", accessTokenType);
    map.put("authorization_code_valid_duration", authorizationCodeValidDuration);
    if (hasDefaultResourceIndicator()) {
      map.put("default_resource_indicator", defaultResourceIndicator);
    }
    if (scopeResourceMapping != null && !scopeResourceMapping.isEmpty()) {
      map.put("scope_resource_mapping", scopeResourceMapping);
    }
    map.put("token_signed_key_id", tokenSignedKeyId);
    map.put("id_token_signed_key_id", idTokenSignedKeyId);
    map.put("access_token_duration", accessTokenDuration);
    map.put("refresh_token_duration", refreshTokenDuration);
    map.put("refresh_token_strategy", refreshTokenStrategy);
    map.put("rotate_refresh_token", rotateRefreshToken);
    map.put("id_token_duration", idTokenDuration);
    map.put("id_token_strict_mode", idTokenStrictMode);
    map.put("default_max_age", defaultMaxAge);
    map.put("authorization_response_duration", authorizationResponseDuration);
    map.put(
        "backchannel_authentication_request_expires_in", backchannelAuthenticationRequestExpiresIn);
    map.put(
        "backchannel_authentication_polling_interval", backchannelAuthenticationPollingInterval);
    map.put("client_attestation_challenge_required", clientAttestationChallengeRequired);
    map.put("client_attestation_challenge_duration", clientAttestationChallengeDuration);
    map.put("dpop_proof_acceptable_window_seconds", dpopProofAcceptableWindowSeconds);
    map.put(
        "client_attestation_pop_acceptable_window_seconds",
        clientAttestationPopAcceptableWindowSeconds);
    map.put("client_assertion_max_lifetime_seconds", clientAssertionMaxLifetimeSeconds);
    map.put("required_backchannel_auth_user_code", requiredBackchannelAuthUserCode);
    map.put("backchannel_auth_user_code_type", backchannelAuthUserCodeType);
    map.put(
        "default_ciba_authentication_interaction_type", defaultCibaAuthenticationInteractionType);
    map.put("oauth_authorization_request_expires_in", oauthAuthorizationRequestExpiresIn);
    map.put("pushed_authorization_request_expires_in", pushedAuthorizationRequestExpiresIn);
    map.put("fapi_baseline_scopes", fapiBaselineScopes);
    map.put("fapi_advance_scopes", fapiAdvanceScopes);
    map.put("fapi20_scopes", fapi20Scopes);
    map.put("required_identity_verification_scopes", requiredIdentityVerificationScopes);
    map.put("custom_claims_scope_mapping", customClaimsScopeMapping);
    map.put(
        "access_token_selective_user_custom_properties", accessTokenSelectiveUserCustomProperties);
    map.put("access_token_selective_verified_claims", accessTokenSelectiveVerifiedClaims);
    map.put("access_token_selective_standard_claims", accessTokenSelectiveStandardClaims);
    map.put("redirect_uri_exact_match_required", redirectUriExactMatchRequired);
    return map;
  }
}
