package org.getopentest.http;

import java.io.*;
import java.nio.charset.Charset;
import java.security.KeyStore;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.HashMap;
import java.util.Map;
import java.util.Map.Entry;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.TrustManagerFactory;
import org.apache.commons.io.IOUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpHost;
import org.apache.http.client.config.CookieSpecs;
import org.apache.http.client.config.RequestConfig;
import org.apache.http.client.methods.*;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.*;
import org.getopentest.contracts.ILogger;
import org.getopentest.logging.ConsoleLogger;

public class HttpRequest implements AutoCloseable {

    private CloseableHttpClient httpClient;

    private HttpRequestBase httpRequest;

    private ILogger logger;

    private CloseableHttpResponse response;

    private String url;

    private HttpVerb httpVerb;

    public HttpRequest(HttpRequestOptions options) {
        this.url = options.url;
        this.httpVerb = options.httpVerb;
        this.httpClient = createHttpClient(options);

        if (options.logger != null) {
            this.logger = options.logger;
        } else {
            this.logger = new ConsoleLogger();
        }

        switch (this.httpVerb) {
            case GET:
                this.httpRequest = new HttpGet(url);
                break;
            case HEAD:
                this.httpRequest = new HttpHead(url);
                break;
            case OPTIONS:
                this.httpRequest = new HttpOptions(url);
                break;
            case PATCH:
                this.httpRequest = new HttpPatch(url);
                break;
            case POST:
                this.httpRequest = new HttpPost(url);
                break;
            case PUT:
                this.httpRequest = new HttpPut(url);
                break;
            case DELETE:
                this.httpRequest = new HttpDelete(url);
                break;
            case DELETE_WITH_BODY:
                this.httpRequest = new HttpDeleteWithBody(url);
                break;
            default:
                throw new RuntimeException(String.format("HTTP verb \"%s\" is not supported", this.httpVerb));
        }

        if (options.proxy != null && !options.proxy.trim().isEmpty()) {
            this.setProxy(options.proxy);
        }

        if (options.headers != null) {
            for (Entry<String, String> entry : options.headers.entrySet()) {
                this.setHeader(entry.getKey(), entry.getValue());
            }
        }

        if (options.basicAuthLogin != null && options.basicAuthPassword != null) {
            setBasicAuth(options.basicAuthLogin, options.basicAuthPassword);
        }
    }

    public void close() {
        try {
            if (this.response != null) {
                this.response.close();
            }
        } catch (IOException exc) {
        }

        try {
            if (this.httpClient != null) {
                this.httpClient.close();
            }
        } catch (IOException exc) {
        }
    }

    private CloseableHttpClient createHttpClient(HttpRequestOptions options) {
        try {
            RequestConfig requestConfig = RequestConfig.custom()
                    .setCookieSpec(CookieSpecs.STANDARD)
                    .build();

            KeyManager[] keyManagers = new KeyManager[0];

            HttpClientBuilder clientBuider = HttpClientBuilder.create()
                    .setConnectionTimeToLive(60, TimeUnit.SECONDS)
                    .disableRedirectHandling()
                    .setDefaultRequestConfig(requestConfig);

            KeyStore keyStore = null;

            // Create KeyManager to use, depending on whether a client
            // certificate was specified or not
            if (options.clientCertificate != null) {
                File clientCertificate = new File(options.clientCertificate);
                if (!clientCertificate.isFile()) {
                    throw new FileNotFoundException(String.format(
                            "Client certificate file %s doesn't exist",
                            clientCertificate));
                }

                String clientCertificateType = null;
                if (clientCertificate.getName().endsWith(".p12")) {
                    clientCertificateType = "PKCS12";
                } else if (clientCertificate.getName().endsWith(".jks")) {
                    clientCertificateType = "JKS";
                } else {
                    throw new RuntimeException(
                            "Client certificate file name should end in \".p12\" or \".jks\".");
                }

                char[] clientCertificatePassword = options.clientCertificatePassword != null
                        ? options.clientCertificatePassword.toCharArray()
                        : new char[0];
                keyStore = KeyStore.getInstance(clientCertificateType);
                keyStore.load(new FileInputStream(clientCertificate), clientCertificatePassword);
                KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());

                keyManagerFactory.init(keyStore, clientCertificatePassword);
                keyManagers = keyManagerFactory.getKeyManagers();
            } else {
                keyStore = KeyStore.getInstance(KeyStore.getDefaultType());
                KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
                keyManagerFactory.init(null, null);
                keyManagers = keyManagerFactory.getKeyManagers();
            }

            TrustManager[] trustManagers;
            if (options.ignoreCert) {
                trustManagers = new TrustManager[]{new NoopTrustManager()};
            } else {
                TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
                trustManagerFactory.init(keyStore);
                trustManagers = trustManagerFactory.getTrustManagers();
            }

            SSLContext sslContext = SSLContext.getInstance("TLS");
            sslContext.init(keyManagers, trustManagers, null);

            if (options.ignoreCert) {
                SSLConnectionSocketFactory sslSocketFactory = new SSLConnectionSocketFactory(
                        sslContext, NoopHostnameVerifier.INSTANCE);

                clientBuider = clientBuider
                        .setSSLSocketFactory(sslSocketFactory);
            }

            return clientBuider.build();
        } catch (Throwable ex) {
            throw new RuntimeException("Failed to create HTTP client", ex);
        }
    }

    // TODO: Take a timeout value and throw an exception in case the HTTP server doesn't respond in due time
    public void execute() throws IOException {
        this.response = this.httpClient.execute(this.httpRequest);
    }

    /**
     * Execute an HTTP request for the specified number of times, waiting the 10
     * seconds between retries. The request is considered successful as long as
     * no exception was thrown.
     */
    public void executeWithRetries(int retries) {
        this.executeWithRetries(retries, 10, 0);
    }

    /**
     * Execute an HTTP request for the specified number of times, waiting the
     * specified number of seconds between retries, until we get the expected
     * HTTP status code. If the expectedStatusCode parameter is 0 we will
     * consider the request successful as long as no exception was thrown.
     */
    public void executeWithRetries(int retries, int waitIntervalSec, int expectedStatusCode) {
        int retriesLeft = retries;
        while (retriesLeft > 0) {
            try {
                this.execute();
                if ((this.getResponseStatusCode() == expectedStatusCode) || expectedStatusCode == 0) {
                    break;
                } else {
                    retriesLeft--;
                    this.logger.warning(String.format(
                            "HTTP request %s returned status code %s but we expected %s. Retries left: %s",
                            this.getUrl(),
                            this.getResponseStatusCode(),
                            expectedStatusCode,
                            retriesLeft));
                }
            } catch (Exception ex) {
                retriesLeft--;
                if (retriesLeft > 0) {
                    this.logger.warning(String.format(
                            "HTTP request %s failed. Retries left: %s",
                            this.getUrl(),
                            retriesLeft), ex);
                } else {
                    throw new RuntimeException(String.format(
                            "Failed sending HTTP request %s after trying %s times",
                            this.getUrl(),
                            retries), ex);
                }
            }

            if (retriesLeft < 0) {
                throw new RuntimeException(String.format(
                        "Failed sending HTTP request %s after trying %s times. Last HTTP "
                        + "status code received was %s.",
                        this.getUrl(),
                        retries,
                        this.getResponseStatusCode()));
            }

            try {
                Thread.sleep(waitIntervalSec * 1000);
            } catch (InterruptedException ex) {
            }
        }
    }

    public HttpVerb getHttpVerb() {
        return this.httpVerb;
    }

    public String getResponseAsString() {
        HttpEntity responseEntity = this.response.getEntity();

        if (responseEntity != null) {
            try {
                InputStream contentStream = responseEntity.getContent();

                if (contentStream != null) {
                    return IOUtils.toString(contentStream, "UTF-8");
                } else {
                    return "";
                }
            } catch (Exception ex) {
                throw new RuntimeException(String.format("Failed to get the response content for HTTP request %s %s",
                        this.httpVerb,
                        this.url), ex);
            }
        } else {
            return "";
        }
    }

    public Map<String, String> getResponseHeaders() {
        Header[] headers = this.response.getAllHeaders();
        Map<String, String> headersMap = new HashMap<String, String>();
        for (Header header : headers) {
            headersMap.put(header.getName(), header.getValue());
        }
        return headersMap;
    }

    public int getResponseStatusCode() {
        if (this.response != null) {
            return this.response.getStatusLine().getStatusCode();
        } else {
            return 0;
        }
    }

    public InputStream getResponseAsStream() throws IOException {
        return this.response.getEntity().getContent();
    }

    public String getUrl() {
        return this.url;
    }

    public String getFirstHeader(String headerName) {
        Header header = this.response.getFirstHeader(headerName);
        return header != null ? header.getValue() : null;
    }

    public void setBasicAuth(String login, String password) {
        String loginAndPassword = String.format("%s:%s", login, password);
        byte[] encodedBytes = Base64.getMimeEncoder().encode(loginAndPassword.getBytes());
        try {
            this.setHeader("Authorization", String.format("Basic %s", new String(encodedBytes, "UTF-8")));
        } catch (Exception exc) {
            throw new RuntimeException("Failed to set the Authorization header", exc);
        }
    }

    public void setContent(String content, String contentType) {
        if (content == null) {
            content = "";
        }

        //TODO: Improve the validation logic
        if (contentType.indexOf('/') <= 0) {
            throw new RuntimeException(String.format("Content type \"%s\" is not a valid MIME type", contentType));
        }

        if (HttpEntityEnclosingRequestBase.class.isInstance(httpRequest)) {
            try {
                StringEntity requestEntity = new StringEntity(content, Charset.forName("UTF-8"));
                ((HttpEntityEnclosingRequestBase) this.httpRequest).setEntity(requestEntity);
                this.httpRequest.setHeader("Content-Type", contentType);
            } catch (Exception ex) {
                throw new RuntimeException("Failed to set HTTP request content", ex);
            }
        }
    }

    public void setHeader(String headerName, String headerValue) {
        this.httpRequest.setHeader(headerName, headerValue);
    }

    public void setProxy(String proxyServer) {
        String proxy = null;
        String proxyPort = null;
        Pattern pattern = Pattern.compile("(?<proxy>.+?)(:(?<port>.+))?");
        Matcher matcher = pattern.matcher(proxyServer.trim());
        if (matcher.matches()) {
            proxy = matcher.group("proxy");
            proxyPort = matcher.group("port");
        } else {
            throw new RuntimeException(String.format("Invalid proxy server:", proxyServer));
        }

        HttpHost proxyHost;
        if (proxyPort != null) {
            proxyHost = new HttpHost(proxy, Integer.valueOf(proxyPort));
        } else {
            proxyHost = new HttpHost(proxy);
        }
        RequestConfig oldConfig = this.httpRequest.getConfig();
        RequestConfig.Builder configBuilder = null;

        if (oldConfig != null) {
            configBuilder = RequestConfig.copy(oldConfig);
        } else {
            configBuilder = RequestConfig.custom().setProxy(proxyHost);
        }

        this.httpRequest.setConfig(configBuilder.build());
    }
}
