package com.airlock.iam.oauth2.infrastructure.repository;

import com.airlock.iam.base.api.domain.model.exception.FailureException;
import com.airlock.iam.base.misc.log.LogUtil;
import com.airlock.iam.core.domain.model.http.HttpRequest;
import com.airlock.iam.core.domain.model.http.HttpRequestHeader;
import com.airlock.iam.core.domain.model.http.HttpRequestParameter;
import com.airlock.iam.core.domain.model.http.HttpResponse;
import com.airlock.iam.core.domain.model.http.client.HttpClient;
import com.airlock.iam.core.domain.service.OAuth2ClientCredentialsGrantAccessTokenLoader;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.oauth2.domain.model.OAuth2Param;
import com.airlock.iam.oauth2.domain.model.client.OAuth2ClientSideCredentials;
import com.airlock.iam.oauth2.domain.model.client.OAuth2RequestBuilder;
import com.airlock.iam.oauth2.domain.model.client.communication.OAuth2TokenRequestClientSecretProcessor;
import com.airlock.iam.oauth2.infrastructure.transfer.response.RemoteAccessTokenResponse;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.benmanes.caffeine.cache.CacheLoader;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.LoadingCache;
import com.github.benmanes.caffeine.cache.RemovalCause;
import com.github.benmanes.caffeine.cache.Ticker;
import com.nimbusds.oauth2.sdk.GrantType;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.CompletionException;
import lombok.NonNull;

public final class HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader implements OAuth2ClientCredentialsGrantAccessTokenLoader {
   private static final ELogger LOG = ELoggerFactory.getLogger(HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.class);
   private final LoadingCache<@NonNull URI, @NonNull AccessTokenCacheEntry> accessTokenCache;

   private HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader(
      @NonNull OAuth2ClientSideCredentials clientCredentials,
      @NonNull OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication,
      @NonNull OAuth2RequestBuilder accessTokenRequestMethod,
      @NonNull HttpClient httpClient,
      @NonNull List<String> scopesToRequest,
      Duration expiresIn,
      Ticker ticker
   ) {
      if (clientCredentials == null) {
         throw new NullPointerException("clientCredentials is marked non-null but is null");
      }

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

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

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

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

      HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader accessTokenCacheLoader = HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.builder()
         .httpClient(httpClient)
         .scopesToRequest(scopesToRequest)
         .clientCredentials(clientCredentials)
         .tokenEndpointAuthentication(tokenEndpointAuthentication)
         .accessTokenRequestMethod(accessTokenRequestMethod)
         .build();
      Caffeine<Object, Object> cacheBuilder = Caffeine.newBuilder()
         .ticker(ticker != null ? ticker : Ticker.systemTicker())
         .removalListener(this::logRemoval)
         .initialCapacity(1);
      if (expiresIn != null) {
         cacheBuilder.expireAfterWrite(expiresIn);
      } else {
         cacheBuilder.expireAfter(new RemoteAccessTokenExpiry());
      }

      this.accessTokenCache = cacheBuilder.build(accessTokenCacheLoader);
   }

   @Override
   public String getAccessToken(URI tokenEndpoint) {
      return this.getAccessToken(tokenEndpoint, false);
   }

   @Override
   public String getAccessToken(URI tokenEndpoint, boolean forceRefresh) {
      try {
         if (forceRefresh) {
            this.accessTokenCache.invalidateAll();
         }

         AccessTokenCacheEntry accessTokenEntry = Objects.requireNonNull(
            (AccessTokenCacheEntry)this.accessTokenCache.get(tokenEndpoint), "Unexpected null value for remote access token response."
         );
         if (accessTokenEntry.isExpired()) {
            this.accessTokenCache.invalidate(tokenEndpoint);
            accessTokenEntry = Objects.requireNonNull(
               (AccessTokenCacheEntry)this.accessTokenCache.get(tokenEndpoint), "Unexpected null value for remote access token response after invalidation."
            );
         }

         return accessTokenEntry.getAccessToken();
      } catch (CompletionException e) {
         throw new FailureException(
            "Failed to fetch Access Token from tokenEndpoint " + LogUtil.logSafeQuote(tokenEndpoint.toASCIIString()) + ": " + e.getMessage(), e
         );
      }
   }

   private void logRemoval(Object key, Object value, RemovalCause cause) {
      LOG.debug(String.format("Access token for %s was removed from cache because of %s.", LogUtil.logSafeQuote(key), LogUtil.logSafeQuote(cause.name())));
   }

   @SuppressFBWarnings(justification = "generated code")
   public static HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder builder() {
      return new HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder();
   }

   @SuppressFBWarnings(justification = "generated code")
   public static class HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder {
      @SuppressFBWarnings(justification = "generated code")
      private OAuth2ClientSideCredentials clientCredentials;
      @SuppressFBWarnings(justification = "generated code")
      private OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication;
      @SuppressFBWarnings(justification = "generated code")
      private OAuth2RequestBuilder accessTokenRequestMethod;
      @SuppressFBWarnings(justification = "generated code")
      private HttpClient httpClient;
      @SuppressFBWarnings(justification = "generated code")
      private List<String> scopesToRequest;
      @SuppressFBWarnings(justification = "generated code")
      private Duration expiresIn;
      @SuppressFBWarnings(justification = "generated code")
      private Ticker ticker;

      @SuppressFBWarnings(justification = "generated code")
      HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder() {
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder clientCredentials(
         @NonNull OAuth2ClientSideCredentials clientCredentials
      ) {
         if (clientCredentials == null) {
            throw new NullPointerException("clientCredentials is marked non-null but is null");
         }

         this.clientCredentials = clientCredentials;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder tokenEndpointAuthentication(
         @NonNull OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication
      ) {
         if (tokenEndpointAuthentication == null) {
            throw new NullPointerException("tokenEndpointAuthentication is marked non-null but is null");
         }

         this.tokenEndpointAuthentication = tokenEndpointAuthentication;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder accessTokenRequestMethod(
         @NonNull OAuth2RequestBuilder accessTokenRequestMethod
      ) {
         if (accessTokenRequestMethod == null) {
            throw new NullPointerException("accessTokenRequestMethod is marked non-null but is null");
         }

         this.accessTokenRequestMethod = accessTokenRequestMethod;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder httpClient(
         @NonNull HttpClient httpClient
      ) {
         if (httpClient == null) {
            throw new NullPointerException("httpClient is marked non-null but is null");
         }

         this.httpClient = httpClient;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder scopesToRequest(
         @NonNull List<String> scopesToRequest
      ) {
         if (scopesToRequest == null) {
            throw new NullPointerException("scopesToRequest is marked non-null but is null");
         }

         this.scopesToRequest = scopesToRequest;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder expiresIn(
         Duration expiresIn
      ) {
         this.expiresIn = expiresIn;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder ticker(Ticker ticker) {
         this.ticker = ticker;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader build() {
         return new HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader(
            this.clientCredentials,
            this.tokenEndpointAuthentication,
            this.accessTokenRequestMethod,
            this.httpClient,
            this.scopesToRequest,
            this.expiresIn,
            this.ticker
         );
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.HttpClientOAuth2ClientCredentialsGrantAccessTokenLoaderBuilder(clientCredentials="
            + this.clientCredentials
            + ", tokenEndpointAuthentication="
            + this.tokenEndpointAuthentication
            + ", accessTokenRequestMethod="
            + this.accessTokenRequestMethod
            + ", httpClient="
            + this.httpClient
            + ", scopesToRequest="
            + this.scopesToRequest
            + ", expiresIn="
            + this.expiresIn
            + ", ticker="
            + this.ticker
            + ")";
      }
   }

   private static class URIAccessTokenCacheLoader implements CacheLoader<URI, AccessTokenCacheEntry> {
      @NonNull
      private final HttpClient httpClient;
      @NonNull
      private final List<String> scopesToRequest;
      @NonNull
      private final OAuth2ClientSideCredentials clientCredentials;
      @NonNull
      private final OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication;
      @NonNull
      private final OAuth2RequestBuilder accessTokenRequestMethod;

      public AccessTokenCacheEntry load(URI location) {
         Objects.requireNonNull(location, "location must not be null.");
         HttpRequest httpRequest = this.buildClientCredentialsGrantRequest(location);
         return this.executeOAuth2Request(httpRequest);
      }

      private HttpRequest buildClientCredentialsGrantRequest(URI location) {
         List<HttpRequestParameter> params = new ArrayList<>();
         List<HttpRequestHeader> headers = new ArrayList<>();
         params.add(HttpRequestParameter.httpRequestParameter(OAuth2Param.GRANT_TYPE.getParameterName(), GrantType.CLIENT_CREDENTIALS.getValue()));
         params.add(HttpRequestParameter.httpRequestParameter(OAuth2Param.CLIENT_ID.getParameterName(), this.clientCredentials.clientId().id()));
         if (!this.scopesToRequest.isEmpty()) {
            params.add(HttpRequestParameter.httpRequestParameter(OAuth2Param.SCOPE.getParameterName(), String.join(" ", this.scopesToRequest)));
         }

         this.tokenEndpointAuthentication.putClientSecretInRequestData(location, params, headers, this.clientCredentials);
         return this.accessTokenRequestMethod.buildRequestWithParams(location, params, headers);
      }

      private AccessTokenCacheEntry executeOAuth2Request(HttpRequest httpRequest) {
         HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.LOG
            .debug(
               String.format("Obtaining access token via OAuth 2.0 Client Credentials Grant from %s.", LogUtil.logSafeQuote(httpRequest.uri().getStringValue()))
            );
         HttpResponse httpResponse = this.httpClient.execute(httpRequest);
         if (isFailure(httpResponse)) {
            throw new FailureException(
               String.format(
                  "Failed to retrieve access token via OAuth2 Client Credentials Grant from %s: returned status %d (response body: %s).",
                  LogUtil.logSafeQuote(httpRequest.uri().getStringValue()),
                  httpResponse.status(),
                  LogUtil.logSafeQuote(httpResponse.body())
               )
            );
         }

         AccessTokenCacheEntry remoteAccessTokenResponse = mapToTokenResponse(httpResponse);
         HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.LOG
            .info(
               String.format(
                  "Successfully obtained access token via OAuth2 Client Credentials Grant from %s. Token expires in %d seconds.",
                  LogUtil.logSafeQuote(httpRequest.uri().getStringValue()),
                  remoteAccessTokenResponse.getExpiresIn().getSeconds()
               )
            );
         return remoteAccessTokenResponse;
      }

      private static AccessTokenCacheEntry mapToTokenResponse(HttpResponse httpResponse) {
         try {
            String responseBody = httpResponse.body();
            ObjectMapper mapper = new ObjectMapper();
            RemoteAccessTokenResponse accessTokenResponse = (RemoteAccessTokenResponse)mapper.readValue(responseBody, RemoteAccessTokenResponse.class);
            return new AccessTokenCacheEntry(Duration.ofSeconds(accessTokenResponse.getExpiresIn()), accessTokenResponse.getAccessToken());
         } catch (JsonProcessingException e) {
            throw new FailureException("Could not parse OAuth2 Client Credentials Grant response: " + e.getMessage(), e);
         }
      }

      private static boolean isFailure(HttpResponse httpResponse) {
         return httpResponse.status() != 200 && httpResponse.status() != 201;
      }

      @SuppressFBWarnings(justification = "generated code")
      URIAccessTokenCacheLoader(
         @NonNull HttpClient httpClient,
         @NonNull List<String> scopesToRequest,
         @NonNull OAuth2ClientSideCredentials clientCredentials,
         @NonNull OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication,
         @NonNull OAuth2RequestBuilder accessTokenRequestMethod
      ) {
         if (httpClient == null) {
            throw new NullPointerException("httpClient is marked non-null but is null");
         }

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

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

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

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

         this.httpClient = httpClient;
         this.scopesToRequest = scopesToRequest;
         this.clientCredentials = clientCredentials;
         this.tokenEndpointAuthentication = tokenEndpointAuthentication;
         this.accessTokenRequestMethod = accessTokenRequestMethod;
      }

      @SuppressFBWarnings(justification = "generated code")
      public static HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder builder() {
         return new HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder();
      }

      @SuppressFBWarnings(justification = "generated code")
      public static class URIAccessTokenCacheLoaderBuilder {
         @SuppressFBWarnings(justification = "generated code")
         private HttpClient httpClient;
         @SuppressFBWarnings(justification = "generated code")
         private List<String> scopesToRequest;
         @SuppressFBWarnings(justification = "generated code")
         private OAuth2ClientSideCredentials clientCredentials;
         @SuppressFBWarnings(justification = "generated code")
         private OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication;
         @SuppressFBWarnings(justification = "generated code")
         private OAuth2RequestBuilder accessTokenRequestMethod;

         @SuppressFBWarnings(justification = "generated code")
         URIAccessTokenCacheLoaderBuilder() {
         }

         @SuppressFBWarnings(justification = "generated code")
         public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder httpClient(
            @NonNull HttpClient httpClient
         ) {
            if (httpClient == null) {
               throw new NullPointerException("httpClient is marked non-null but is null");
            }

            this.httpClient = httpClient;
            return this;
         }

         @SuppressFBWarnings(justification = "generated code")
         public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder scopesToRequest(
            @NonNull List<String> scopesToRequest
         ) {
            if (scopesToRequest == null) {
               throw new NullPointerException("scopesToRequest is marked non-null but is null");
            }

            this.scopesToRequest = scopesToRequest;
            return this;
         }

         @SuppressFBWarnings(justification = "generated code")
         public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder clientCredentials(
            @NonNull OAuth2ClientSideCredentials clientCredentials
         ) {
            if (clientCredentials == null) {
               throw new NullPointerException("clientCredentials is marked non-null but is null");
            }

            this.clientCredentials = clientCredentials;
            return this;
         }

         @SuppressFBWarnings(justification = "generated code")
         public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder tokenEndpointAuthentication(
            @NonNull OAuth2TokenRequestClientSecretProcessor tokenEndpointAuthentication
         ) {
            if (tokenEndpointAuthentication == null) {
               throw new NullPointerException("tokenEndpointAuthentication is marked non-null but is null");
            }

            this.tokenEndpointAuthentication = tokenEndpointAuthentication;
            return this;
         }

         @SuppressFBWarnings(justification = "generated code")
         public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder accessTokenRequestMethod(
            @NonNull OAuth2RequestBuilder accessTokenRequestMethod
         ) {
            if (accessTokenRequestMethod == null) {
               throw new NullPointerException("accessTokenRequestMethod is marked non-null but is null");
            }

            this.accessTokenRequestMethod = accessTokenRequestMethod;
            return this;
         }

         @SuppressFBWarnings(justification = "generated code")
         public HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader build() {
            return new HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader(
               this.httpClient, this.scopesToRequest, this.clientCredentials, this.tokenEndpointAuthentication, this.accessTokenRequestMethod
            );
         }

         @SuppressFBWarnings(justification = "generated code")
         @Override
         public String toString() {
            return "HttpClientOAuth2ClientCredentialsGrantAccessTokenLoader.URIAccessTokenCacheLoader.URIAccessTokenCacheLoaderBuilder(httpClient="
               + this.httpClient
               + ", scopesToRequest="
               + this.scopesToRequest
               + ", clientCredentials="
               + this.clientCredentials
               + ", tokenEndpointAuthentication="
               + this.tokenEndpointAuthentication
               + ", accessTokenRequestMethod="
               + this.accessTokenRequestMethod
               + ")";
         }
      }
   }
}
