/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You 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.apache.celeborn.rest.v1.worker.invoker;

import com.fasterxml.jackson.annotation.*;
import com.fasterxml.jackson.databind.*;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import java.time.OffsetDateTime;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JavaType;
import org.openapitools.jackson.nullable.JsonNullableModule;

import org.apache.hc.client5.http.cookie.BasicCookieStore;
import org.apache.hc.client5.http.cookie.Cookie;
import org.apache.hc.client5.http.entity.UrlEncodedFormEntity;
import org.apache.hc.client5.http.entity.mime.MultipartEntityBuilder;
import org.apache.hc.client5.http.impl.classic.CloseableHttpClient;
import org.apache.hc.client5.http.impl.classic.CloseableHttpResponse;
import org.apache.hc.client5.http.impl.classic.HttpClients;
import org.apache.hc.client5.http.impl.cookie.BasicClientCookie;
import org.apache.hc.client5.http.protocol.HttpClientContext;
import org.apache.hc.core5.http.ContentType;
import org.apache.hc.core5.http.Header;
import org.apache.hc.core5.http.HttpEntity;
import org.apache.hc.core5.http.HttpResponse;
import org.apache.hc.core5.http.HttpStatus;
import org.apache.hc.core5.http.NameValuePair;
import org.apache.hc.core5.http.ParseException;
import org.apache.hc.core5.http.io.entity.ByteArrayEntity;
import org.apache.hc.core5.http.io.entity.EntityUtils;
import org.apache.hc.core5.http.io.entity.FileEntity;
import org.apache.hc.core5.http.io.entity.StringEntity;
import org.apache.hc.core5.http.io.support.ClassicRequestBuilder;
import org.apache.hc.core5.http.message.BasicNameValuePair;

import java.util.Collection;
import java.util.Collections;
import java.util.Map;
import java.util.Map.Entry;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.Date;
import java.util.function.Supplier;
import java.util.TimeZone;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

import java.net.URLEncoder;

import java.io.File;
import java.io.InputStream;
import java.io.IOException;
import java.io.UnsupportedEncodingException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.charset.UnsupportedCharsetException;
import java.nio.file.Files;
import java.nio.file.StandardCopyOption;
import java.nio.file.Paths;
import java.lang.reflect.Type;
import java.net.URI;

import java.text.DateFormat;

import org.apache.celeborn.rest.v1.worker.invoker.auth.Authentication;
import org.apache.celeborn.rest.v1.worker.invoker.auth.HttpBasicAuth;

@javax.annotation.Generated(value = "org.openapitools.codegen.languages.JavaClientCodegen", comments = "Generator version: 7.8.0")
public class ApiClient extends JavaTimeFormatter {
  private Map<String, String> defaultHeaderMap = new HashMap<String, String>();
  private Map<String, String> defaultCookieMap = new HashMap<String, String>();
  private String basePath = "http://localhost:9096";
  protected List<ServerConfiguration> servers = new ArrayList<ServerConfiguration>(Arrays.asList(
    new ServerConfiguration(
      "http://localhost:9096",
      "Localhost reference server",
      new HashMap<String, ServerVariable>()
    )
  ));
  protected Integer serverIndex = 0;
  protected Map<String, String> serverVariables = null;
  private boolean debugging = false;
  private int connectionTimeout = 0;

  private CloseableHttpClient httpClient;
  private ObjectMapper objectMapper;
  protected String tempFolderPath = null;

  private Map<String, Authentication> authentications;

  private Map<Long, Integer> lastStatusCodeByThread = new ConcurrentHashMap<>();
  private Map<Long, Map<String, List<String>>> lastResponseHeadersByThread = new ConcurrentHashMap<>();

  private DateFormat dateFormat;

  // Methods that can have a request body
  private static List<String> bodyMethods = Arrays.asList("POST", "PUT", "DELETE", "PATCH");

  public ApiClient(CloseableHttpClient httpClient) {
    objectMapper = new ObjectMapper();
    objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
    objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
    objectMapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false);
    objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
    objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
    objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
    objectMapper.registerModule(new JavaTimeModule());
    objectMapper.registerModule(new JsonNullableModule());
    objectMapper.setDateFormat(ApiClient.buildDefaultDateFormat());

    dateFormat = ApiClient.buildDefaultDateFormat();

    // Set default User-Agent.
    setUserAgent("OpenAPI-Generator/0.7.0-SNAPSHOT/java");

    // Setup authentications (key: authentication name, value: authentication).
    authentications = new HashMap<String, Authentication>();
    authentications.put("basic", new HttpBasicAuth());
    // Prevent the authentications from being modified.
    authentications = Collections.unmodifiableMap(authentications);

    this.httpClient = httpClient;
  }

  public ApiClient() {
    this(HttpClients.createDefault());
  }

  public static DateFormat buildDefaultDateFormat() {
    return new RFC3339DateFormat();
  }

  /**
   * Returns the current object mapper used for JSON serialization/deserialization.
   * <p>
   * Note: If you make changes to the object mapper, remember to set it back via
   * <code>setObjectMapper</code> in order to trigger HTTP client rebuilding.
   * </p>
   * @return Object mapper
   */
  public ObjectMapper getObjectMapper() {
    return objectMapper;
  }

  /**
   * Sets the object mapper.
   *
   * @param objectMapper object mapper
   * @return API client
   */
  public ApiClient setObjectMapper(ObjectMapper objectMapper) {
    this.objectMapper = objectMapper;
    return this;
  }

  public CloseableHttpClient getHttpClient() {
    return httpClient;
  }

  /**
   * Sets the HTTP client.
   *
   * @param httpClient HTTP client
   * @return API client
   */
  public ApiClient setHttpClient(CloseableHttpClient httpClient) {
    this.httpClient = httpClient;
    return this;
  }

  public String getBasePath() {
    return basePath;
  }

  /**
   * Sets the base path.
   *
   * @param basePath base path
   * @return API client
   */
  public ApiClient setBasePath(String basePath) {
    this.basePath = basePath;
    this.serverIndex = null;
    return this;
  }

  public List<ServerConfiguration> getServers() {
    return servers;
  }

  /**
   * Sets the server.
   *
   * @param servers a list of server configuration
   * @return API client
   */
  public ApiClient setServers(List<ServerConfiguration> servers) {
    this.servers = servers;
    return this;
  }

  public Integer getServerIndex() {
    return serverIndex;
  }

  /**
   * Sets the server index.
   *
   * @param serverIndex server index
   * @return API client
   */
  public ApiClient setServerIndex(Integer serverIndex) {
    this.serverIndex = serverIndex;
    return this;
  }

  public Map<String, String> getServerVariables() {
    return serverVariables;
  }

  /**
   * Sets the server variables.
   *
   * @param serverVariables server variables
   * @return API client
   */
  public ApiClient setServerVariables(Map<String, String> serverVariables) {
    this.serverVariables = serverVariables;
    return this;
  }

  /**
   * Gets the status code of the previous request
   *
   * @return Status code
   */
  @Deprecated
  public int getStatusCode() {
    return lastStatusCodeByThread.get(Thread.currentThread().getId());
  }

  /**
   * Gets the response headers of the previous request
   * @return Response headers
   */
  @Deprecated
  public Map<String, List<String>> getResponseHeaders() {
    return lastResponseHeadersByThread.get(Thread.currentThread().getId());
  }

  /**
   * Get authentications (key: authentication name, value: authentication).
   * @return Map of authentication
   */
  public Map<String, Authentication> getAuthentications() {
    return authentications;
  }

  /**
   * Get authentication for the given name.
   *
   * @param authName The authentication name
   * @return The authentication, null if not found
   */
  public Authentication getAuthentication(String authName) {
    return authentications.get(authName);
  }

  /**
   * The path of temporary folder used to store downloaded files from endpoints
   * with file response. The default value is <code>null</code>, i.e. using
   * the system's default temporary folder.
   *
   * @return Temp folder path
   */
  public String getTempFolderPath() {
    return tempFolderPath;
  }


  /**
   * Helper method to set username for the first HTTP basic authentication.
   * @param username Username
   * @return API client
   */
  public ApiClient setUsername(String username) {
    for (Authentication auth : authentications.values()) {
      if (auth instanceof HttpBasicAuth) {
        ((HttpBasicAuth) auth).setUsername(username);
        return this;
      }
    }
    throw new RuntimeException("No HTTP basic authentication configured!");
  }

  /**
   * Helper method to set password for the first HTTP basic authentication.
   * @param password Password
   * @return API client
   */
  public ApiClient setPassword(String password) {
    for (Authentication auth : authentications.values()) {
      if (auth instanceof HttpBasicAuth) {
        ((HttpBasicAuth) auth).setPassword(password);
        return this;
      }
    }
    throw new RuntimeException("No HTTP basic authentication configured!");
  }




  /**
   * Set the User-Agent header's value (by adding to the default header map).
   * @param userAgent User agent
   * @return API client
   */
  public ApiClient setUserAgent(String userAgent) {
    addDefaultHeader("User-Agent", userAgent);
    return this;
  }

  /**
   * Set temp folder path
   * @param tempFolderPath Temp folder path
   * @return API client
   */
  public ApiClient setTempFolderPath(String tempFolderPath) {
    this.tempFolderPath = tempFolderPath;
    return this;
  }

  /**
   * Add a default header.
   *
   * @param key The header's key
   * @param value The header's value
   * @return API client
   */
  public ApiClient addDefaultHeader(String key, String value) {
    defaultHeaderMap.put(key, value);
    return this;
  }

  /**
   * Add a default cookie.
   *
   * @param key The cookie's key
   * @param value The cookie's value
   * @return API client
   */
  public ApiClient addDefaultCookie(String key, String value) {
    defaultCookieMap.put(key, value);
    return this;
  }

  /**
   * Check that whether debugging is enabled for this API client.
   * @return True if debugging is on
   */
  public boolean isDebugging() {
    return debugging;
  }

  /**
   * Enable/disable debugging for this API client.
   *
   * @param debugging To enable (true) or disable (false) debugging
   * @return API client
   */
  public ApiClient setDebugging(boolean debugging) {
    // TODO: implement debugging mode
    this.debugging = debugging;
    return this;
  }

  /**
   * Connect timeout (in milliseconds).
   * @return Connection timeout
   */
  public int getConnectTimeout() {
    return connectionTimeout;
  }

  /**
   * Set the connect timeout (in milliseconds).
   * A value of 0 means no timeout, otherwise values must be between 1 and
   * {@link Integer#MAX_VALUE}.
   * @param connectionTimeout Connection timeout in milliseconds
   * @return API client
   */
   public ApiClient setConnectTimeout(int connectionTimeout) {
     this.connectionTimeout = connectionTimeout;
     return this;
   }

  /**
   * Get the date format used to parse/format date parameters.
   * @return Date format
   */
  public DateFormat getDateFormat() {
    return dateFormat;
  }

  /**
   * Set the date format used to parse/format date parameters.
   * @param dateFormat Date format
   * @return API client
   */
  public ApiClient setDateFormat(DateFormat dateFormat) {
    this.dateFormat = dateFormat;
    // Also set the date format for model (de)serialization with Date properties.
    this.objectMapper.setDateFormat((DateFormat) dateFormat.clone());
    return this;
  }

  /**
   * Parse the given string into Date object.
   * @param str String
   * @return Date
   */
  public Date parseDate(String str) {
    try {
      return dateFormat.parse(str);
    } catch (java.text.ParseException e) {
      throw new RuntimeException(e);
    }
  }

  /**
   * Format the given Date object into string.
   * @param date Date
   * @return Date in string format
   */
  public String formatDate(Date date) {
    return dateFormat.format(date);
  }

  /**
   * Format the given parameter object into string.
   * @param param Object
   * @return Object in string format
   */
  public String parameterToString(Object param) {
    if (param == null) {
      return "";
    } else if (param instanceof Date) {
      return formatDate((Date) param);
    } else if (param instanceof OffsetDateTime) {
      return formatOffsetDateTime((OffsetDateTime) param);
    } else if (param instanceof Collection) {
      StringBuilder b = new StringBuilder();
      for(Object o : (Collection<?>)param) {
        if(b.length() > 0) {
          b.append(',');
        }
        b.append(String.valueOf(o));
      }
      return b.toString();
    } else {
      return String.valueOf(param);
    }
  }

  /**
   * Formats the specified query parameter to a list containing a single {@code Pair} object.
   *
   * Note that {@code value} must not be a collection.
   *
   * @param name The name of the parameter.
   * @param value The value of the parameter.
   * @return A list containing a single {@code Pair} object.
   */
  public List<Pair> parameterToPair(String name, Object value) {
    List<Pair> params = new ArrayList<Pair>();

    // preconditions
    if (name == null || name.isEmpty() || value == null || value instanceof Collection) {
      return params;
    }

    params.add(new Pair(name, escapeString(parameterToString(value))));
    return params;
  }

  /**
   * Formats the specified collection query parameters to a list of {@code Pair} objects.
   *
   * Note that the values of each of the returned Pair objects are percent-encoded.
   *
   * @param collectionFormat The collection format of the parameter.
   * @param name The name of the parameter.
   * @param value The value of the parameter.
   * @return A list of {@code Pair} objects.
   */
  public List<Pair> parameterToPairs(String collectionFormat, String name, Collection value) {
    List<Pair> params = new ArrayList<Pair>();

    // preconditions
    if (name == null || name.isEmpty() || value == null || value.isEmpty()) {
      return params;
    }

    // create the params based on the collection format
    if ("multi".equals(collectionFormat)) {
      for (Object item : value) {
        params.add(new Pair(name, escapeString(parameterToString(item))));
      }
      return params;
    }

    // collectionFormat is assumed to be "csv" by default
    String delimiter = ",";

    // escape all delimiters except commas, which are URI reserved
    // characters
    if ("ssv".equals(collectionFormat)) {
      delimiter = escapeString(" ");
    } else if ("tsv".equals(collectionFormat)) {
      delimiter = escapeString("\t");
    } else if ("pipes".equals(collectionFormat)) {
      delimiter = escapeString("|");
    }

    StringBuilder sb = new StringBuilder() ;
    for (Object item : value) {
      sb.append(delimiter);
      sb.append(escapeString(parameterToString(item)));
    }

    params.add(new Pair(name, sb.substring(delimiter.length())));

    return params;
  }

  /**
   * Check if the given MIME is a JSON MIME.
   * JSON MIME examples:
   *   application/json
   *   application/json; charset=UTF8
   *   APPLICATION/JSON
   *   application/vnd.company+json
   * @param mime MIME
   * @return True if MIME type is boolean
   */
  public boolean isJsonMime(String mime) {
    String jsonMime = "(?i)^(application/json|[^;/ \t]+/[^;/ \t]+[+]json)[ \t]*(;.*)?$";
    return mime != null && (mime.matches(jsonMime) || mime.equals("*/*"));
  }

  /**
   * Select the Accept header's value from the given accepts array:
   *   if JSON exists in the given array, use it;
   *   otherwise use all of them (joining into a string)
   *
   * @param accepts The accepts array to select from
   * @return The Accept header to use. If the given array is empty,
   *   null will be returned (not to set the Accept header explicitly).
   */
  public String selectHeaderAccept(String[] accepts) {
    if (accepts.length == 0) {
      return null;
    }
    for (String accept : accepts) {
      if (isJsonMime(accept)) {
        return accept;
      }
    }
    return StringUtil.join(accepts, ",");
  }

  /**
   * Select the Content-Type header's value from the given array:
   *   if JSON exists in the given array, use it;
   *   otherwise use the first one of the array.
   *
   * @param contentTypes The Content-Type array to select from
   * @return The Content-Type header to use. If the given array is empty,
   *   or matches "any", JSON will be used.
   */
  public String selectHeaderContentType(String[] contentTypes) {
    if (contentTypes.length == 0 || contentTypes[0].equals("*/*")) {
      return "application/json";
    }
    for (String contentType : contentTypes) {
      if (isJsonMime(contentType)) {
        return contentType;
      }
    }
    return contentTypes[0];
  }

  /**
   * Escape the given string to be used as URL query value.
   * @param str String
   * @return Escaped string
   */
  public String escapeString(String str) {
    try {
      return URLEncoder.encode(str, "utf8").replaceAll("\\+", "%20");
    } catch (UnsupportedEncodingException e) {
      return str;
    }
  }

  /**
   * Transforms response headers into map.
   *
   * @param headers HTTP headers
   * @return a map of string array
   */
  protected Map<String, List<String>> transformResponseHeaders(Header[] headers) {
    Map<String, List<String>> headersMap = new HashMap<>();
    for (Header header : headers) {
      List<String> valuesList = headersMap.get(header.getName());
      if (valuesList != null) {
        valuesList.add(header.getValue());
      } else {
        valuesList = new ArrayList<>();
        valuesList.add(header.getValue());
        headersMap.put(header.getName(), valuesList);
      }
    }
    return headersMap;
  }

  /**
   * Parse content type object from header value
   */
  private ContentType getContentType(String headerValue) throws ApiException {
    try {
      return ContentType.parse(headerValue);
    } catch (UnsupportedCharsetException e) {
      throw new ApiException("Could not parse content type " + headerValue);
    }
  }

  /**
   * Get content type of a response or null if one was not provided
   */
  private String getResponseMimeType(HttpResponse response) throws ApiException {
    Header contentTypeHeader = response.getFirstHeader("Content-Type");
    if (contentTypeHeader != null) {
      return getContentType(contentTypeHeader.getValue()).getMimeType();
    }
    return null;
  }

  /**
   * Serialize the given Java object into string according the given
   * Content-Type (only JSON is supported for now).
   * @param obj Object
   * @param contentType Content type
   * @param formParams Form parameters
   * @return Object
   * @throws ApiException API exception
   */
  public HttpEntity serialize(Object obj, Map<String, Object> formParams, ContentType contentType) throws ApiException {
    String mimeType = contentType.getMimeType();
    if (isJsonMime(mimeType)) {
      try {
        return new StringEntity(objectMapper.writeValueAsString(obj), contentType.withCharset(StandardCharsets.UTF_8));
      } catch (JsonProcessingException e) {
        throw new ApiException(e);
      }
    } else if (mimeType.equals(ContentType.MULTIPART_FORM_DATA.getMimeType())) {
      MultipartEntityBuilder multiPartBuilder = MultipartEntityBuilder.create();
      for (Entry<String, Object> paramEntry : formParams.entrySet()) {
        Object value = paramEntry.getValue();
        if (value instanceof File) {
          multiPartBuilder.addBinaryBody(paramEntry.getKey(), (File) value);
        } else if (value instanceof byte[]) {
          multiPartBuilder.addBinaryBody(paramEntry.getKey(), (byte[]) value);
        } else {
          Charset charset = contentType.getCharset();
          if (charset != null) {
            ContentType customContentType = ContentType.create(ContentType.TEXT_PLAIN.getMimeType(), charset);
            multiPartBuilder.addTextBody(paramEntry.getKey(), parameterToString(paramEntry.getValue()),
                    customContentType);
          } else {
            multiPartBuilder.addTextBody(paramEntry.getKey(), parameterToString(paramEntry.getValue()));
          }
        }
      }
      return multiPartBuilder.build();
    } else if (mimeType.equals(ContentType.APPLICATION_FORM_URLENCODED.getMimeType())) {
      List<NameValuePair> formValues = new ArrayList<>();
      for (Entry<String, Object> paramEntry : formParams.entrySet()) {
        formValues.add(new BasicNameValuePair(paramEntry.getKey(), parameterToString(paramEntry.getValue())));
      }
      return new UrlEncodedFormEntity(formValues, contentType.getCharset());
    } else {
      // Handle files with unknown content type
      if (obj instanceof File) {
        return new FileEntity((File) obj, contentType);
      } else if (obj instanceof byte[]) {
        return new ByteArrayEntity((byte[]) obj, contentType);
      }
      throw new ApiException("Serialization for content type '" + contentType + "' not supported");
    }
  }

  /**
   * Deserialize response body to Java object according to the Content-Type.
   *
   * @param <T> Type
   * @param response Response
   * @param valueType Return type
   * @return Deserialized object
   * @throws ApiException API exception
   * @throws IOException IO exception
   */
  @SuppressWarnings("unchecked")
  public <T> T deserialize(CloseableHttpResponse response, TypeReference<T> valueType) throws ApiException, IOException, ParseException {
    if (valueType == null) {
      return null;
    }
    HttpEntity entity = response.getEntity();
    Type valueRawType = valueType.getType();
    if (valueRawType.equals(byte[].class)) {
      return (T) EntityUtils.toByteArray(entity);
    } else if (valueRawType.equals(File.class)) {
      return (T) downloadFileFromResponse(response);
    }
    String mimeType = getResponseMimeType(response);
    if (mimeType == null || isJsonMime(mimeType)) {
      // Assume json if no mime type
      // convert input stream to string
      String content = EntityUtils.toString(entity);

      if ("".equals(content)) { // returns null for empty body
        return null;
      }

      return objectMapper.readValue(content, valueType);
    } else if (mimeType.toLowerCase().startsWith("text/")) {
      // convert input stream to string
      return (T) EntityUtils.toString(entity);
    } else {
      Map<String, List<String>> responseHeaders = transformResponseHeaders(response.getHeaders());
      throw new ApiException(
          "Deserialization for content type '" + mimeType + "' not supported for type '" + valueType + "'",
          response.getCode(),
          responseHeaders,
          EntityUtils.toString(entity)
      );
    }
  }

  private File downloadFileFromResponse(CloseableHttpResponse response) throws IOException {
    Header contentDispositionHeader = response.getFirstHeader("Content-Disposition");
    String contentDisposition = contentDispositionHeader == null ? null : contentDispositionHeader.getValue();
    File file = prepareDownloadFile(contentDisposition);
    Files.copy(response.getEntity().getContent(), file.toPath(), StandardCopyOption.REPLACE_EXISTING);
    return file;
  }

  protected File prepareDownloadFile(String contentDisposition) throws IOException {
    String filename = null;
    if (contentDisposition != null && !"".equals(contentDisposition)) {
      // Get filename from the Content-Disposition header.
      Pattern pattern = Pattern.compile("filename=['\"]?([^'\"\\s]+)['\"]?");
      Matcher matcher = pattern.matcher(contentDisposition);
      if (matcher.find())
        filename = matcher.group(1);
    }

    String prefix;
    String suffix = null;
    if (filename == null) {
      prefix = "download-";
      suffix = "";
    } else {
      int pos = filename.lastIndexOf('.');
      if (pos == -1) {
        prefix = filename + "-";
      } else {
        prefix = filename.substring(0, pos) + "-";
        suffix = filename.substring(pos);
      }
      // Files.createTempFile requires the prefix to be at least three characters long
      if (prefix.length() < 3)
        prefix = "download-";
    }

    if (tempFolderPath == null)
      return Files.createTempFile(prefix, suffix).toFile();
    else
      return Files.createTempFile(Paths.get(tempFolderPath), prefix, suffix).toFile();
  }

  /**
   * Returns the URL of the client as defined by the server (if exists) or the base path.
   *
   * @return The URL for the client.
   */
  public String getBaseURL() {
    String baseURL;
    if (serverIndex != null) {
      if (serverIndex < 0 || serverIndex >= servers.size()) {
        throw new ArrayIndexOutOfBoundsException(String.format(
          "Invalid index %d when selecting the host settings. Must be less than %d", serverIndex, servers.size()
        ));
      }
      baseURL = servers.get(serverIndex).URL(serverVariables);
    } else {
      baseURL = basePath;
    }
    return baseURL;
  }

  /**
   * Build full URL by concatenating base URL, the given sub path and query parameters.
   *
   * @param path The sub path
   * @param queryParams The query parameters
   * @param collectionQueryParams The collection query parameters
   * @param urlQueryDeepObject URL query string of the deep object parameters
   * @return The full URL
   */
  private String buildUrl(String path, List<Pair> queryParams, List<Pair> collectionQueryParams, String urlQueryDeepObject) {
    String baseURL = getBaseURL();

    final StringBuilder url = new StringBuilder();
    url.append(baseURL).append(path);

    if (queryParams != null && !queryParams.isEmpty()) {
      // support (constant) query string in `path`, e.g. "/posts?draft=1"
      String prefix = path.contains("?") ? "&" : "?";
      for (Pair param : queryParams) {
        if (param.getValue() != null) {
          if (prefix != null) {
            url.append(prefix);
            prefix = null;
          } else {
            url.append("&");
          }
          String value = parameterToString(param.getValue());
          // query parameter value already escaped as part of parameterToPair
          url.append(escapeString(param.getName())).append("=").append(value);
        }
      }
    }

    if (collectionQueryParams != null && !collectionQueryParams.isEmpty()) {
      String prefix = url.toString().contains("?") ? "&" : "?";
      for (Pair param : collectionQueryParams) {
        if (param.getValue() != null) {
          if (prefix != null) {
            url.append(prefix);
            prefix = null;
          } else {
            url.append("&");
          }
          String value = parameterToString(param.getValue());
          // collection query parameter value already escaped as part of parameterToPairs
          url.append(escapeString(param.getName())).append("=").append(value);
        }
      }
    }

    if (urlQueryDeepObject != null && urlQueryDeepObject.length() > 0) {
      url.append(url.toString().contains("?") ? "&" : "?");
      url.append(urlQueryDeepObject);
    }

    return url.toString();
  }

  protected boolean isSuccessfulStatus(int statusCode) {
    return statusCode >= 200 && statusCode < 300;
  }

  protected boolean isBodyAllowed(String method) {
    return bodyMethods.contains(method);
  }

  protected Cookie buildCookie(String key, String value, URI uri) {
    BasicClientCookie cookie = new BasicClientCookie(key, value);
    cookie.setDomain(uri.getHost());
    cookie.setPath("/");
    return cookie;
  }

  protected <T> T processResponse(CloseableHttpResponse response, TypeReference<T> returnType) throws ApiException, IOException, ParseException {
    int statusCode = response.getCode();
    lastStatusCodeByThread.put(Thread.currentThread().getId(), statusCode);
    if (statusCode == HttpStatus.SC_NO_CONTENT) {
      return null;
    }

    Map<String, List<String>> responseHeaders = transformResponseHeaders(response.getHeaders());
    lastResponseHeadersByThread.put(Thread.currentThread().getId(), responseHeaders);

    if (isSuccessfulStatus(statusCode)) {
      return this.deserialize(response, returnType);
    } else {
      String message = EntityUtils.toString(response.getEntity());
      throw new ApiException(message, statusCode, responseHeaders, message);
    }
  }

  /**
   * Invoke API by sending HTTP request with the given options.
   *
   * @param <T> Type
   * @param path The sub-path of the HTTP URL
   * @param method The request method, one of "GET", "POST", "PUT", and "DELETE"
   * @param queryParams The query parameters
   * @param collectionQueryParams The collection query parameters
   * @param urlQueryDeepObject A URL query string for deep object parameters
   * @param body The request body object - if it is not binary, otherwise null
   * @param headerParams The header parameters
   * @param cookieParams The cookie parameters
   * @param formParams The form parameters
   * @param accept The request's Accept header
   * @param contentType The request's Content-Type header
   * @param authNames The authentications to apply
   * @param returnType Return type
   * @return The response body in type of string
   * @throws ApiException API exception
   */
   public <T> T invokeAPI(
       String path,
       String method,
       List<Pair> queryParams,
       List<Pair> collectionQueryParams,
       String urlQueryDeepObject,
       Object body,
       Map<String, String> headerParams,
       Map<String, String> cookieParams,
       Map<String, Object> formParams,
       String accept,
       String contentType,
       String[] authNames,
       TypeReference<T> returnType) throws ApiException {
    if (body != null && !formParams.isEmpty()) {
      throw new ApiException("Cannot have body and form params");
    }

    updateParamsForAuth(authNames, queryParams, headerParams, cookieParams);
    final String url = buildUrl(path, queryParams, collectionQueryParams, urlQueryDeepObject);

    ClassicRequestBuilder builder = ClassicRequestBuilder.create(method);
    builder.setUri(url);

    if (accept != null) {
      builder.addHeader("Accept", accept);
    }
    for (Entry<String, String> keyValue : headerParams.entrySet()) {
      builder.addHeader(keyValue.getKey(), keyValue.getValue());
    }
    for (Map.Entry<String,String> keyValue : defaultHeaderMap.entrySet()) {
      if (!headerParams.containsKey(keyValue.getKey())) {
        builder.addHeader(keyValue.getKey(), keyValue.getValue());
      }
    }

    BasicCookieStore store = new BasicCookieStore();
    for (Entry<String, String> keyValue : cookieParams.entrySet()) {
      store.addCookie(buildCookie(keyValue.getKey(), keyValue.getValue(), builder.getUri()));
    }
    for (Entry<String,String> keyValue : defaultCookieMap.entrySet()) {
      if (!cookieParams.containsKey(keyValue.getKey())) {
        store.addCookie(buildCookie(keyValue.getKey(), keyValue.getValue(), builder.getUri()));
      }
    }

    HttpClientContext context = HttpClientContext.create();
    context.setCookieStore(store);

    ContentType contentTypeObj = getContentType(contentType);
    if (body != null || !formParams.isEmpty()) {
      if (isBodyAllowed(method)) {
        // Add entity if we have content and a valid method
        builder.setEntity(serialize(body, formParams, contentTypeObj));
      } else {
        throw new ApiException("method " + method + " does not support a request body");
      }
    } else {
      // for empty body
      builder.setEntity(new StringEntity("", contentTypeObj));
    }

    try (CloseableHttpResponse response = httpClient.execute(builder.build(), context)) {
      return processResponse(response, returnType);
    } catch (IOException | ParseException e) {
      throw new ApiException(e);
    }
  }

  /**
   * Update query and header parameters based on authentication settings.
   *
   * @param authNames The authentications to apply
   * @param queryParams Query parameters
   * @param headerParams Header parameters
   * @param cookieParams Cookie parameters
   */
  private void updateParamsForAuth(String[] authNames, List<Pair> queryParams, Map<String, String> headerParams, Map<String, String> cookieParams) {
    for (String authName : authNames) {
      Authentication auth = authentications.get(authName);
      if (auth == null) throw new RuntimeException("Authentication undefined: " + authName);
      auth.applyToParams(queryParams, headerParams, cookieParams);
    }
  }
}
