package com.airlock.iam.common.infrastructure.restclient;

import com.airlock.iam.common.api.domain.model.exception.ExternalServiceUnavailableException;
import com.airlock.iam.core.application.configuration.basicauth.BasicAuthCredentialsConfig;
import com.airlock.iam.core.misc.util.httpclient.PoolingHttpClientConnectionManagerFactory;
import com.airlock.iam.elog.api.ELogger;
import com.airlock.iam.elog.api.ELoggerFactory;
import com.airlock.iam.elog.jul.ElogBasedJulLogger;
import com.airlock.iam.security.util.crypto.KeyStoreUtils;
import com.fasterxml.jackson.jakarta.rs.json.JacksonJsonProvider;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Suppliers;
import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;
import jakarta.annotation.Nonnull;
import jakarta.ws.rs.ProcessingException;
import jakarta.ws.rs.client.Client;
import jakarta.ws.rs.client.ClientBuilder;
import jakarta.ws.rs.client.Invocation;
import jakarta.ws.rs.core.Configuration;
import jakarta.ws.rs.core.Response;
import java.io.File;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.logging.Level;
import java.util.logging.Logger;
import org.apache.hc.client5.http.config.RequestConfig;
import org.apache.hc.client5.http.config.RequestConfig.Builder;
import org.apache.hc.client5.http.io.HttpClientConnectionManager;
import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
import org.apache.hc.core5.http.io.SocketConfig;
import org.apache.hc.core5.ssl.SSLContextBuilder;
import org.apache.hc.core5.ssl.SSLContexts;
import org.apache.hc.core5.util.Timeout;
import org.glassfish.jersey.apache5.connector.Apache5ConnectorProvider;
import org.glassfish.jersey.client.ClientConfig;
import org.glassfish.jersey.client.authentication.HttpAuthenticationFeature;
import org.glassfish.jersey.logging.LoggingFeature;
import org.glassfish.jersey.logging.LoggingFeature.Verbosity;

public final class JerseyRestClient implements RestClient {
   private static final ELogger LOG = ELoggerFactory.getLogger(JerseyRestClient.class);
   private static final String DEFAULT_KEYSTORE_TYPE = "JKS";
   private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(30L);
   private final Optional<File> keyStorePath;
   private final Optional<String> keyStorePassword;
   private final Optional<File> trustStorePath;
   private final Optional<String> trustStorePassword;
   private final List<String> serverUrls;
   private final RestClientRetryPolicy retryPolicy;
   private final Duration timeout;
   private final Duration connectionManagerTimeout;
   private final int connectionPoolMaxSize;
   private final Optional<BasicAuthCredentialsConfig> basicAuthCredentials;
   private final Optional<RestClientProxySettings> proxySettings;
   private final RequestEntityProcessing requestEntityProcessing;
   private final Optional<StaticRestRequestHeaderService> staticRestRequestHeaderService;
   private final Supplier<Client> client;
   private int lastWorkingServerIndex;

   JerseyRestClient(
      @Nonnull List<String> serverUrls,
      @Nonnull RestClientRetryPolicy retryPolicy,
      @Nonnull Duration timeout,
      int connectionPoolMaxSize,
      File keyStorePath,
      String keyStorePassword,
      File trustStorePath,
      String trustStorePassword,
      BasicAuthCredentialsConfig basicAuthCredentials,
      RestClientProxySettings proxySettings,
      @Nonnull RequestEntityProcessing requestEntityProcessing,
      StaticRestRequestHeaderService staticRestRequestHeaderService,
      Duration connectionManagerTimeout
   ) {
      this.serverUrls = serverUrls;
      this.retryPolicy = retryPolicy;
      this.timeout = safeConnectTimeout(timeout);
      this.connectionPoolMaxSize = Math.max(connectionPoolMaxSize, 1);
      this.requestEntityProcessing = requestEntityProcessing;
      this.keyStorePath = Optional.ofNullable(keyStorePath);
      this.keyStorePassword = Optional.ofNullable(keyStorePassword);
      this.trustStorePath = Optional.ofNullable(trustStorePath);
      this.trustStorePassword = Optional.ofNullable(trustStorePassword);
      this.basicAuthCredentials = Optional.ofNullable(basicAuthCredentials);
      this.staticRestRequestHeaderService = Optional.ofNullable(staticRestRequestHeaderService);
      this.proxySettings = Optional.ofNullable(proxySettings);
      this.connectionManagerTimeout = connectionManagerTimeout;
      this.client = Suppliers.memoize(this::createHttpClient);
      this.validate();
   }

   private void validate() {
      if (this.serverUrls.isEmpty()) {
         throw new RestClientException("At least one server URL must be set");
      }

      this.keyStorePath.ifPresent(path -> createKeyStore(path, this.keyStorePassword));
      this.trustStorePath.ifPresent(path -> createKeyStore(path, this.trustStorePassword));
   }

   private static Duration safeConnectTimeout(Duration inputTimeout) {
      return inputTimeout.isPositive() ? inputTimeout : DEFAULT_TIMEOUT;
   }

   @VisibleForTesting
   Duration getConnectAndReadTimeout() {
      return this.timeout;
   }

   @Override
   public RestResponse issueRequest(RestRequest request) throws RestClientException {
      return this.sendRequest(request);
   }

   @Override
   public <T> T read(RestRequest request, Function<RestResponse, T> responseHandler) throws RestClientException {
      try (RestResponse response = this.sendRequest(request)) {
         return responseHandler.apply(response);
      }
   }

   @Override
   public void send(RestRequest request, Consumer<RestResponse> responseHandler) throws RestClientException {
      try (RestResponse response = this.sendRequest(request)) {
         responseHandler.accept(response);
      }
   }

   private RestResponse sendRequest(RestRequest request) {
      int startIndex = this.lastWorkingServerIndex;
      int currentIndex = this.lastWorkingServerIndex;
      if (this.staticRestRequestHeaderService.isPresent()) {
         request = this.staticRestRequestHeaderService.get().addStaticRequestHeaders(request);
      }

      String currentServerUri = this.serverUrls.get(currentIndex);

      do {
         if (this.serverUrls.size() > 1) {
            LOG.debug("Using server base URL at index " + currentIndex + ": " + currentServerUri);
         }

         Optional<RestResponse> response = this.issueRequestToUrl(request, currentServerUri);
         if (response.isPresent()) {
            this.lastWorkingServerIndex = currentIndex;
            return response.get();
         }

         currentIndex = (currentIndex + 1) % this.serverUrls.size();
         currentServerUri = this.serverUrls.get(currentIndex);
      } while (currentIndex != startIndex);

      throw new ExternalServiceUnavailableException(
         "Could not find a reachable REST server for request " + request.getDescription(), request.getUrl(currentServerUri)
      );
   }

   private Optional<RestResponse> issueRequestToUrl(RestRequest request, String serverUrl) {
      try {
         Response response = invoke(this.client.get(), request, serverUrl);
         if (this.retryPolicy.retryRequestWithResponse(response)) {
            LOG.debug("The retry policy indicates that the request must be retried.");
            response.close();
            return Optional.empty();
         } else {
            return Optional.of(new RestResponse(response, request.getUrl(serverUrl)));
         }
      } catch (ProcessingException e) {
         if (this.retryPolicy.retryRequestWithProcessingException(e)) {
            LOG.debug("A ProcessingException occurred and the retry policy indicates that the request should be retried.", e);
            return Optional.empty();
         } else {
            throw new ExternalServiceUnavailableException("Failure during REST call: " + e.getMessage(), request.getUrl(serverUrl), e);
         }
      }
   }

   private static Response invoke(Client client, RestRequest request, String baseUrl) {
      LOG.debug("Sending request: " + request.getMethod() + " " + request.getUrl(baseUrl));
      Invocation invocation = request.toInvocation(client, baseUrl);
      return invocation.invoke();
   }

   private Client createHttpClient() {
      ClientBuilder clientBuilder = ClientBuilder.newBuilder();
      this.keyStorePath
         .map(path -> createKeyStore(path, this.keyStorePassword))
         .ifPresent(
            keyStore -> clientBuilder.keyStore(keyStore, this.keyStorePassword.orElseThrow(() -> new RestClientException("Missing key store password")))
         );
      this.trustStorePath.map(path -> createKeyStore(path, this.trustStorePassword)).ifPresent(clientBuilder::trustStore);
      clientBuilder.register(createLoggingFeature());
      clientBuilder.withConfig(this.createClientConfig());
      Client newClient = clientBuilder.build();
      this.createBasicAuthCredentialsFeature().ifPresent(newClient::register);
      return newClient;
   }

   private static KeyStore createKeyStore(File path, Optional<String> password) {
      try {
         return KeyStoreUtils.createKeyStoreFromFile(path, password, "JKS");
      } catch (KeyStoreException e) {
         throw new RestClientException("Generation of a KeyStore failed", e);
      }
   }

   private Optional<HttpAuthenticationFeature> createBasicAuthCredentialsFeature() {
      return this.basicAuthCredentials.map(credentials -> HttpAuthenticationFeature.basic(credentials.getUsername(), credentials.getPassword()));
   }

   private static LoggingFeature createLoggingFeature() {
      Logger logger = new ElogBasedJulLogger(LOG, Level.FINER);
      return new LoggingFeature(logger, Level.FINER, Verbosity.HEADERS_ONLY, 8192);
   }

   private Configuration createClientConfig() {
      ClientConfig config = new ClientConfig();
      config.property("jersey.config.apache5.client.connectionManager", this.createConnectionManager());
      config.connectorProvider(new Apache5ConnectorProvider());
      config.register(JacksonJsonProvider.class);
      config.property("jersey.config.client.followRedirects", false);
      config.property("jersey.config.client.connectTimeout", this.timeout.toMillis());
      config.property("jersey.config.client.readTimeout", this.timeout.toMillis());
      config.property("jersey.config.client.request.entity.processing", this.requestEntityProcessing.name());
      config.property("jersey.config.apache5.client.handleCookies", true);
      config.property("jersey.config.apache5.client.requestConfig", this.requestConfig());
      this.proxySettings.ifPresent(proxy -> {
         config.property("jersey.config.client.proxy.uri", proxy.uri().toASCIIString());
         proxy.username().ifPresent(username -> config.property("jersey.config.client.proxy.username", username));
         proxy.password().ifPresent(password -> config.property("jersey.config.client.proxy.password", password));
      });
      return config;
   }

   private RequestConfig requestConfig() {
      Builder builder = RequestConfig.custom().setCookieSpec("ignore");
      if (this.timeout.isPositive()) {
         builder.setResponseTimeout(Timeout.of(this.timeout));
      }

      if (this.connectionManagerTimeout != null && this.connectionManagerTimeout.isPositive()) {
         builder.setConnectionRequestTimeout(Timeout.of(this.connectionManagerTimeout));
      }

      return builder.build();
   }

   private HttpClientConnectionManager createConnectionManager() {
      return PoolingHttpClientConnectionManagerFactory.createDefaultConnectionManager(this.createSslSocketFactory(), this.timeout)
         .setMaxConnTotal(this.connectionPoolMaxSize)
         .setMaxConnPerRoute(this.connectionPoolMaxSize)
         .setDefaultSocketConfig(this.socketConfig())
         .build();
   }

   private SocketConfig socketConfig() {
      return SocketConfig.custom().setSoTimeout(Timeout.of(this.timeout)).build();
   }

   private DefaultClientTlsStrategy createSslSocketFactory() {
      try {
         SSLContextBuilder contextBuilder = SSLContexts.custom();
         if (this.keyStorePath.isPresent()) {
            char[] storeAndKeyPassword = this.keyStorePassword
               .map(String::toCharArray)
               .orElseThrow(() -> new RestClientException("Missing key store password"));
            contextBuilder.loadKeyMaterial(this.keyStorePath.get(), storeAndKeyPassword, storeAndKeyPassword);
         }

         if (this.trustStorePath.isPresent()) {
            contextBuilder.loadTrustMaterial(
               this.trustStorePath.get(),
               this.trustStorePassword.map(String::toCharArray).orElseThrow(() -> new RestClientException("Missing trust store password"))
            );
         }

         return new DefaultClientTlsStrategy(contextBuilder.build());
      } catch (IOException e) {
         throw new RestClientException("Failed to load trust/key store", e);
      } catch (GeneralSecurityException e) {
         throw new RestClientException("Failed to create SSLConnectionSocketFactory", e);
      }
   }

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

   @SuppressFBWarnings(justification = "generated code")
   public static class JerseyRestClientBuilder {
      @SuppressFBWarnings(justification = "generated code")
      private List<String> serverUrls;
      @SuppressFBWarnings(justification = "generated code")
      private RestClientRetryPolicy retryPolicy;
      @SuppressFBWarnings(justification = "generated code")
      private Duration timeout;
      @SuppressFBWarnings(justification = "generated code")
      private int connectionPoolMaxSize;
      @SuppressFBWarnings(justification = "generated code")
      private File keyStorePath;
      @SuppressFBWarnings(justification = "generated code")
      private String keyStorePassword;
      @SuppressFBWarnings(justification = "generated code")
      private File trustStorePath;
      @SuppressFBWarnings(justification = "generated code")
      private String trustStorePassword;
      @SuppressFBWarnings(justification = "generated code")
      private BasicAuthCredentialsConfig basicAuthCredentials;
      @SuppressFBWarnings(justification = "generated code")
      private RestClientProxySettings proxySettings;
      @SuppressFBWarnings(justification = "generated code")
      private RequestEntityProcessing requestEntityProcessing;
      @SuppressFBWarnings(justification = "generated code")
      private StaticRestRequestHeaderService staticRestRequestHeaderService;
      @SuppressFBWarnings(justification = "generated code")
      private Duration connectionManagerTimeout;

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

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder serverUrls(@Nonnull List<String> serverUrls) {
         if (serverUrls == null) {
            throw new NullPointerException("serverUrls is marked non-null but is null");
         }

         this.serverUrls = serverUrls;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder retryPolicy(@Nonnull RestClientRetryPolicy retryPolicy) {
         if (retryPolicy == null) {
            throw new NullPointerException("retryPolicy is marked non-null but is null");
         }

         this.retryPolicy = retryPolicy;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder timeout(@Nonnull Duration timeout) {
         if (timeout == null) {
            throw new NullPointerException("timeout is marked non-null but is null");
         }

         this.timeout = timeout;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder connectionPoolMaxSize(int connectionPoolMaxSize) {
         this.connectionPoolMaxSize = connectionPoolMaxSize;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder keyStorePath(File keyStorePath) {
         this.keyStorePath = keyStorePath;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder keyStorePassword(String keyStorePassword) {
         this.keyStorePassword = keyStorePassword;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder trustStorePath(File trustStorePath) {
         this.trustStorePath = trustStorePath;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder trustStorePassword(String trustStorePassword) {
         this.trustStorePassword = trustStorePassword;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder basicAuthCredentials(BasicAuthCredentialsConfig basicAuthCredentials) {
         this.basicAuthCredentials = basicAuthCredentials;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder proxySettings(RestClientProxySettings proxySettings) {
         this.proxySettings = proxySettings;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder requestEntityProcessing(@Nonnull RequestEntityProcessing requestEntityProcessing) {
         if (requestEntityProcessing == null) {
            throw new NullPointerException("requestEntityProcessing is marked non-null but is null");
         }

         this.requestEntityProcessing = requestEntityProcessing;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder staticRestRequestHeaderService(StaticRestRequestHeaderService staticRestRequestHeaderService) {
         this.staticRestRequestHeaderService = staticRestRequestHeaderService;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient.JerseyRestClientBuilder connectionManagerTimeout(Duration connectionManagerTimeout) {
         this.connectionManagerTimeout = connectionManagerTimeout;
         return this;
      }

      @SuppressFBWarnings(justification = "generated code")
      public JerseyRestClient build() {
         return new JerseyRestClient(
            this.serverUrls,
            this.retryPolicy,
            this.timeout,
            this.connectionPoolMaxSize,
            this.keyStorePath,
            this.keyStorePassword,
            this.trustStorePath,
            this.trustStorePassword,
            this.basicAuthCredentials,
            this.proxySettings,
            this.requestEntityProcessing,
            this.staticRestRequestHeaderService,
            this.connectionManagerTimeout
         );
      }

      @SuppressFBWarnings(justification = "generated code")
      @Override
      public String toString() {
         return "JerseyRestClient.JerseyRestClientBuilder(serverUrls="
            + this.serverUrls
            + ", retryPolicy="
            + this.retryPolicy
            + ", timeout="
            + this.timeout
            + ", connectionPoolMaxSize="
            + this.connectionPoolMaxSize
            + ", keyStorePath="
            + this.keyStorePath
            + ", keyStorePassword="
            + this.keyStorePassword
            + ", trustStorePath="
            + this.trustStorePath
            + ", trustStorePassword="
            + this.trustStorePassword
            + ", basicAuthCredentials="
            + this.basicAuthCredentials
            + ", proxySettings="
            + this.proxySettings
            + ", requestEntityProcessing="
            + this.requestEntityProcessing
            + ", staticRestRequestHeaderService="
            + this.staticRestRequestHeaderService
            + ", connectionManagerTimeout="
            + this.connectionManagerTimeout
            + ")";
      }
   }
}
