package com.airlock.iam.oauth2.domain.service.persistence;

import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.api.domain.model.exception.ViolationException;
import com.airlock.iam.common.api.domain.model.persistency.exception.PersisterException;
import com.airlock.iam.core.misc.persistency.token.TokenBean;
import com.airlock.iam.core.misc.persistency.token.TokenDataProvider;
import com.airlock.iam.core.misc.token.KnownTokenTypes;
import com.airlock.iam.core.misc.util.password.hash.RawPasswordHash;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.oauth2.api.domain.model.AuthorizationServerId;
import com.airlock.iam.oauth2.domain.model.logging.OAuth2TokenRepresentation;
import com.airlock.iam.oauth2.domain.model.parser.RawOAuth2TokenParser;
import com.airlock.iam.oauth2.domain.model.token.AbstractTokenTableOAuth2Token;
import com.airlock.iam.oauth2.domain.model.token.LinkedTokenReference;
import com.airlock.iam.oauth2.domain.model.token.OAuth2AccessToken;
import com.airlock.iam.oauth2.domain.model.token.OAuth2RefreshToken;
import com.airlock.iam.oauth2.domain.model.token.OAuth2Token;
import com.airlock.iam.oauth2.domain.model.token.OAuth2TokenTableAccessToken;
import com.airlock.iam.oauth2.domain.model.token.OAuth2TokenTableAuthorizationCode;
import com.airlock.iam.oauth2.domain.model.token.OAuth2TokenTableRefreshToken;
import com.airlock.iam.oauth2.domain.model.token.OAuth2TokenWithHash;
import com.airlock.iam.oauth2.domain.model.token.OpenIDConnectIDToken;
import com.airlock.iam.oauth2.domain.model.token.OpenIDConnectTokenTableIDToken;
import com.airlock.iam.oauth2.domain.model.token.RawOAuth2Token;
import com.airlock.iam.oauth2.domain.service.OAuth2TokenFilterFactory;
import com.airlock.iam.plugin.api.config.LogTypes;
import com.google.common.annotations.VisibleForTesting;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;
import lombok.NonNull;
import org.apache.commons.codec.binary.Base64;

public class OAuth2TokenReader {
   @NonNull
   private final TokenDataProvider tokenDataProvider;
   @NonNull
   private final RawPasswordHash hashFunction;
   @NonNull
   private final ELogger logger;
   @NonNull
   private final OAuth2TokenRepresentation tokenRepresentation;
   @NonNull
   private final AuthorizationServerId authorizationServerId;

   @VisibleForTesting
   static OAuth2Token getSpecificToken(TokenBean delegate) {
      if (delegate != null && delegate.getType().equals(KnownTokenTypes.OAUTH2.getId())) {
         OAuth2Token.OAuth2TokenType subtype = AbstractTokenTableOAuth2Token.getTokenTypeForDbIdentifier(delegate.getGenericDataElement1());

         return switch (subtype) {
            case AUTHORIZATION_CODE -> new OAuth2TokenTableAuthorizationCode(delegate);
            case ACCESS_TOKEN -> new OAuth2TokenTableAccessToken(delegate);
            case REFRESH_TOKEN -> new OAuth2TokenTableRefreshToken(delegate);
            case OPENID_CONNECT_ID_TOKEN -> new OpenIDConnectTokenTableIDToken(delegate);
         };
      } else {
         throw new IllegalStateException("Cannot instantiate OAuth 2.0 Token from this generic token delegate (null or wrong token type).");
      }
   }

   public Optional<OpenIDConnectIDToken> getIDTokenWithSession(String sessionID) throws PersisterException {
      List<TokenBean> idTokenList = this.tokenDataProvider
         .get(
            OAuth2TokenFilterFactory.oAuth2TokenFilterForAs(this.authorizationServerId)
               .filterGenericDataElement1(AbstractTokenTableOAuth2Token.getDbIdentifierForTokenType(OAuth2Token.OAuth2TokenType.OPENID_CONNECT_ID_TOKEN))
               .filterGenericDataElement6(sessionID)
         );
      if (idTokenList.size() > 1) {
         this.logger.warn(LogTypes.PLUGIN_INFO, "Multiple OpenID Connect ID Tokens found with same session " + sessionID + "! Returning absent.");
         return Optional.empty();
      } else if (idTokenList.isEmpty()) {
         this.logger.info(LogTypes.PLUGIN_INFO, "No OpenID Connect ID Token found for session " + sessionID + ".");
         return Optional.empty();
      } else {
         return Optional.of((OpenIDConnectIDToken)getSpecificToken(idTokenList.getFirst()));
      }
   }

   public List<OAuth2Token> getActiveTokensOfSession(String sessionIdentifier) {
      TokenDataProvider.TokenFilter tokenFilter = OAuth2TokenFilterFactory.oAuth2TokenFilterForAs(this.authorizationServerId)
         .filterActive(true)
         .filterGenericDataElement6(sessionIdentifier);
      return this.getTokens(tokenFilter);
   }

   public Optional<OAuth2AccessToken> getAccessTokenLinkedWithRefreshToken(OAuth2RefreshToken refreshToken) throws PersisterException {
      if (!refreshToken.hasAccessTokenReference()) {
         return Optional.empty();
      }

      List<TokenBean> tokenList = this.tokenDataProvider
         .get(this.getFilterForAccessTokenLinkedWithRefreshToken(refreshToken, refreshToken.getAccessTokenReference().orElseThrow()));
      if (tokenList.size() > 1) {
         this.logger
            .warn(
               LogTypes.PLUGIN_INFO,
               "Multiple OAuth 2.0 Access Tokens found with same key and hash! Disregard existence. If this occurs often, the space of possible token strings is way too small and compromises security."
            );
         return Optional.empty();
      }

      if (tokenList.isEmpty()) {
         return Optional.empty();
      }

      OAuth2Token oauth2Token = getSpecificToken(tokenList.getFirst());
      return Optional.of((OAuth2AccessToken)oauth2Token);
   }

   public List<OAuth2RefreshToken> getOtherActiveRefreshTokensWithSameSession(OAuth2RefreshToken refreshToken) throws PersisterException {
      return this.tokenDataProvider
         .get(
            OAuth2TokenFilterFactory.oAuth2TokenFilterForAs(this.authorizationServerId)
               .filterActive(true)
               .filterGenericDataElement1(AbstractTokenTableOAuth2Token.getDbIdentifierForTokenType(OAuth2Token.OAuth2TokenType.REFRESH_TOKEN))
               .filterGenericDataElement6(refreshToken.getSessionId().getId())
         )
         .stream()
         .map(OAuth2TokenReader::getSpecificToken)
         .map(OAuth2RefreshToken.class::cast)
         .filter(token -> !token.getHashedTokenSecret().equals(refreshToken.getHashedTokenSecret()))
         .collect(Collectors.toList());
   }

   public Optional<OAuth2Token> getActiveOrInactiveTokenFromProviderWithFullTokenString(String tokenString, OAuth2Token.OAuth2TokenType subtype) throws PersisterException {
      Optional<RawOAuth2Token> rawToken = this.tokenParser().parseRawOAuth2Token(tokenString);
      return rawToken.isPresent() ? this.getTokenFromProvider(rawToken.get(), subtype) : Optional.empty();
   }

   public Optional<OAuth2Token> getActiveTokenFromProviderWithFullTokenString(String tokenString, OAuth2Token.OAuth2TokenType subtype) throws PersisterException {
      Optional<RawOAuth2Token> token = this.tokenParser().parseRawOAuth2Token(tokenString);
      return token.isPresent() ? this.getActiveTokenFromProvider(token.get(), subtype) : Optional.empty();
   }

   private TokenDataProvider.TokenFilter getFilterForAccessTokenLinkedWithRefreshToken(OAuth2RefreshToken refreshToken, LinkedTokenReference accessTokenRef) {
      TokenDataProvider.TokenFilter filter = OAuth2TokenFilterFactory.oAuth2TokenFilterForAs(this.authorizationServerId)
         .filterGenericDataElement1(AbstractTokenTableOAuth2Token.getDbIdentifierForTokenType(OAuth2Token.OAuth2TokenType.ACCESS_TOKEN))
         .filterSingleUser(refreshToken.getUserId().getValue())
         .filterGenericDataElement2(accessTokenRef.getTokenHash())
         .filterGenericDataElement6(refreshToken.getSessionId().getId());
      if (accessTokenRef.getTokenKey().isPresent()) {
         filter.filterSerialId(accessTokenRef.getTokenKey().get().getValue());
      }

      return filter;
   }

   private Optional<OAuth2Token> getActiveTokenFromProvider(RawOAuth2Token rawToken, OAuth2Token.OAuth2TokenType subtype) throws PersisterException {
      TokenDataProvider.TokenFilter filter = this.createTokenFilterForKeyLookup(rawToken, subtype);
      filter.filterActive(true);
      return getTokenByFilter(rawToken, filter, this.hashFunction, this.tokenDataProvider);
   }

   private Optional<OAuth2Token> getTokenFromProvider(RawOAuth2Token rawToken, OAuth2Token.OAuth2TokenType subtype) throws PersisterException {
      TokenDataProvider.TokenFilter filter = this.createTokenFilterForKeyLookup(rawToken, subtype);
      return getTokenByFilter(rawToken, filter, this.hashFunction, this.tokenDataProvider);
   }

   private TokenDataProvider.TokenFilter createTokenFilterForKeyLookup(RawOAuth2Token rawToken, OAuth2Token.OAuth2TokenType subtype) {
      return OAuth2TokenFilterFactory.oAuth2TokenFilterForAs(this.authorizationServerId)
         .filterGenericDataElement1(AbstractTokenTableOAuth2Token.getDbIdentifierForTokenType(subtype))
         .filterSerialId(rawToken.getTokenKey().getValue());
   }

   private List<OAuth2Token> getTokens(TokenDataProvider.TokenFilter filter) {
      try {
         return this.tokenDataProvider.get(filter).stream().map(OAuth2TokenReader::getSpecificToken).collect(Collectors.toList());
      } catch (PersisterException e) {
         throw new FailureException(e);
      }
   }

   private static Optional<OAuth2Token> getTokenByFilter(
      RawOAuth2Token rawToken, TokenDataProvider.TokenFilter filter, RawPasswordHash passwordHash, TokenDataProvider tokenDataProvider
   ) throws PersisterException {
      for (TokenBean tokenBean : tokenDataProvider.get(filter)) {
         OAuth2Token token = getSpecificToken(tokenBean);
         if (!(token instanceof OAuth2TokenWithHash)) {
            throw new ViolationException("OAuth 2.0 Token of type " + token.getOAuth2TokenSubtype() + " is no token with a hashed secret.");
         }

         if (passwordHash.checkPasswordHash(rawToken.getSecretTokenString(), Base64.decodeBase64(((OAuth2TokenWithHash)token).getHashedTokenSecret()))) {
            return Optional.of(token);
         }
      }

      return Optional.empty();
   }

   private RawOAuth2TokenParser tokenParser() {
      return new RawOAuth2TokenParser(this.tokenRepresentation);
   }

   @SuppressFBWarnings(justification = "generated code")
   public OAuth2TokenReader(
      @NonNull TokenDataProvider tokenDataProvider,
      @NonNull RawPasswordHash hashFunction,
      @NonNull ELogger logger,
      @NonNull OAuth2TokenRepresentation tokenRepresentation,
      @NonNull AuthorizationServerId authorizationServerId
   ) {
      if (tokenDataProvider == null) {
         throw new NullPointerException("tokenDataProvider is marked non-null but is null");
      }

      if (hashFunction == null) {
         throw new NullPointerException("hashFunction is marked non-null but is null");
      }

      if (logger == null) {
         throw new NullPointerException("logger is marked non-null but is null");
      }

      if (tokenRepresentation == null) {
         throw new NullPointerException("tokenRepresentation is marked non-null but is null");
      }

      if (authorizationServerId == null) {
         throw new NullPointerException("authorizationServerId is marked non-null but is null");
      }

      this.tokenDataProvider = tokenDataProvider;
      this.hashFunction = hashFunction;
      this.logger = logger;
      this.tokenRepresentation = tokenRepresentation;
      this.authorizationServerId = authorizationServerId;
   }
}
