package ru.yandex.wmtools.common.util;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.UnsupportedCharsetException;
import java.security.KeyManagementException;
import java.security.NoSuchAlgorithmException;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;

import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;

import org.apache.commons.io.IOUtils;
import org.apache.http.Header;
import org.apache.http.HttpEntity;
import org.apache.http.HttpEntityEnclosingRequest;
import org.apache.http.HttpHost;
import org.apache.http.HttpRequest;
import org.apache.http.HttpStatus;
import org.apache.http.ProtocolException;
import org.apache.http.client.RedirectStrategy;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpHead;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpPut;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.params.ClientPNames;
import org.apache.http.client.protocol.RequestAcceptEncoding;
import org.apache.http.conn.params.ConnRoutePNames;
import org.apache.http.conn.scheme.PlainSocketFactory;
import org.apache.http.conn.scheme.Scheme;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.conn.ssl.SSLSocketFactory;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.client.DefaultRedirectStrategy;
import org.apache.http.impl.conn.PoolingClientConnectionManager;
import org.apache.http.params.BasicHttpParams;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpProtocolParams;
import org.apache.http.protocol.HTTP;
import org.apache.http.protocol.HttpContext;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import ru.yandex.wmtools.common.util.connector.ResponseContentEncodingInterceptor;

/**
 * @author Andrey Mima (amima@yandex-team.ru)
 */
public class HttpConnector {
    private static final Logger log = LoggerFactory.getLogger(HttpConnector.class);

    public static final String WEBMASTER_USER_AGENT =
            "Mozilla/5.0 (compatible; YandexWebmaster/2.0; +http://yandex.com/bots)";
    public static final String SPIDER_USER_AGENT =
            "Mozilla/5.0 (compatible; YandexBot/3.0; +http://yandex.com/bots)";
    private static final int POOL_CLIENTS = 32;

    private static final long REFRESH_LOW_BOUND = 100l;   // ms
    private static final long REFRESH_HIGH_BOUND = 5000l; // ms

    private static final DefaultHttpClient[] httpClientPool;
    private static final boolean[] httpClientPoolAvailability;

    private static RedirectStrategy defaultRedirectStrategy = new DefaultRedirectStrategy(); //new CustomRedirectStrategy();
    private static RedirectStrategy noRedirectStrategy = new RedirectStrategy() {
        @Override
        public boolean isRedirected(HttpRequest httpRequest, org.apache.http.HttpResponse httpResponse, HttpContext httpContext) throws ProtocolException {
            return false;
        }

        @Override
        public HttpUriRequest getRedirect(HttpRequest httpRequest, org.apache.http.HttpResponse httpResponse, HttpContext httpContext) throws ProtocolException {
            return null;
        }
    };

    static {
        httpClientPool = new DefaultHttpClient[POOL_CLIENTS];
        httpClientPoolAvailability = new boolean[POOL_CLIENTS];

        for (int i = 0; i < POOL_CLIENTS; i++) {
            SchemeRegistry schemeRegistry = new SchemeRegistry();
            schemeRegistry.register(new Scheme("http", PlainSocketFactory.getSocketFactory(), 80));
            schemeRegistry.register(new Scheme("https", getSSLSocketFactory(), 443));

            PoolingClientConnectionManager manager = new PoolingClientConnectionManager(schemeRegistry);
            manager.setMaxTotal(100);
            manager.setDefaultMaxPerRoute(20);

            BasicHttpParams basicHttpParams = new BasicHttpParams();
            HttpConnectionParams.setConnectionTimeout(basicHttpParams, 10000);
            HttpConnectionParams.setSoTimeout(basicHttpParams, 30000);

            DefaultHttpClient defaultHttpClient = new DefaultHttpClient(manager, basicHttpParams);
            defaultHttpClient.addResponseInterceptor(new ResponseContentEncodingInterceptor());

            httpClientPool[i] = defaultHttpClient;
            httpClientPoolAvailability[i] = true;
        }
    }

    public enum HttpMethod {
        GET,
        POST,
        HEAD,
        PUT
    }

    public static final class RequestBuilder {
        private final URL url;

        // default param values
        private int socketTimeout = 5000;
        private int connectionTimeout = 10000;
        private boolean okStatusRequired = false;
        private String userAgent = WEBMASTER_USER_AGENT;
        private HttpMethod method = HttpMethod.GET;
        private final Map<String, String> headers = new HashMap<String, String>();
        private HttpEntity entity;
        private RedirectStrategy redirectStrategy = defaultRedirectStrategy;
        private Integer maxRedirects;
        private HttpHost proxy;
        private boolean reloadOnRefresh = false;
        // Не выставляем Accept-Encoding по-умолчанию, чтобы по возможности не получать ошибки с gzip
        private boolean useAcceptEncodingHeader = false;
        private Long maxContentSize = null;
        private OutputStream contentOutputStream;

        public RequestBuilder(URL url) {
            try {
                // spider sends requests using lowercase hostname, so do we
                String urlString = url.toString();
                urlString = urlString.replace(url.getHost(), url.getHost().toLowerCase());
                this.url = new URL(urlString);
            } catch (MalformedURLException e) {
                throw new AssertionError("invalid url");
            }
        }

        public RequestBuilder(RequestBuilder builder) {
            this.url = builder.url;
            this.socketTimeout = builder.socketTimeout;
            this.connectionTimeout = builder.connectionTimeout;
            this.okStatusRequired = builder.okStatusRequired;
            this.userAgent = builder.userAgent;
            this.method = builder.method;
            this.headers.putAll(builder.headers);
            this.entity = builder.entity;
            this.redirectStrategy = builder.redirectStrategy;
            this.maxRedirects = builder.maxRedirects;
            this.proxy = builder.proxy;
            this.reloadOnRefresh = builder.reloadOnRefresh;
            this.useAcceptEncodingHeader = builder.useAcceptEncodingHeader;
        }

        public RequestBuilder socketTimeout(int socketTimeout) {
            this.socketTimeout = socketTimeout;
            return this;
        }

        public RequestBuilder redirectStrategy(RedirectStrategy redirectStrategy) {
            this.redirectStrategy = redirectStrategy;
            return this;
        }

        public RequestBuilder connectionTimeout(int connectionTimeout) {
            this.connectionTimeout = connectionTimeout;
            return this;
        }

        public RequestBuilder allowRedirect(boolean allowRedirect) {
            redirectStrategy = allowRedirect ? defaultRedirectStrategy : noRedirectStrategy;
            return this;
        }

        public RequestBuilder userAgent(String userAgent) {
            this.userAgent = userAgent;
            return this;
        }

        public RequestBuilder method(HttpMethod method) {
            this.method = method;
            return this;
        }

        public RequestBuilder okStatusRequired(boolean okStatusRequired) {
            this.okStatusRequired = okStatusRequired;
            return this;
        }

        public RequestBuilder header(String header, String value) {
            headers.put(header, value);
            return this;
        }

        public RequestBuilder entity(String entity, String charset) {
            try {
                this.entity = new StringEntity(entity, charset);
            } catch (UnsupportedCharsetException e) {
                throw new RuntimeException(e);
            }
            return this;
        }

        public RequestBuilder entity(HttpEntity entity) {
            this.entity = entity;
            return this;
        }

        public synchronized HttpResponse execute() throws IOException {
            return executeQuery(this);
        }

        public RequestBuilder setMaxRedirects(Integer maxRedirects) {
            this.maxRedirects = maxRedirects;
            return this;
        }

        public RequestBuilder setProxy(HttpHost proxy) {
            this.proxy = proxy;
            return this;
        }

        public RequestBuilder setMaxContentSize(Long maxContentSize) {
            this.maxContentSize = maxContentSize;
            return this;
        }

        /**
         * Обрабатывать заголовки refresh
         *
         * @param applyReloadOnRefresh
         * @return
         */
        public RequestBuilder reloadOnRefresh(boolean applyReloadOnRefresh) {
            this.reloadOnRefresh = applyReloadOnRefresh;
            return this;
        }

        public RequestBuilder useAcceptEncodingHeader(boolean useAcceptEncodingHeader) {
            this.useAcceptEncodingHeader = useAcceptEncodingHeader;
            return this;
        }

        public RequestBuilder setContentOutputStream(OutputStream contentOutputStream) {
            this.contentOutputStream = contentOutputStream;
            return this;
        }
    }

    private HttpConnector() {
    }

    private static synchronized HttpResponse executeQuerySynchronized(RequestBuilder queryParams,
                                                                      long startTime) throws IOException {
        log.debug("lock time 2: " + (System.currentTimeMillis() - startTime));
        return doExecuteQuery(queryParams, true);
    }

    private static HttpResponse executeQuery(RequestBuilder queryParams) throws IOException {
        return doExecuteQuery(queryParams, false);
    }

    private static HttpResponse doExecuteQuery(RequestBuilder queryParams, boolean forceSyncronized) throws IOException {
        int clientPoolIndex = 0;
        if (!forceSyncronized) {
            long startTime = System.currentTimeMillis();
            clientPoolIndex = poolGetAndLock();
            log.debug("lock time 1: " + (System.currentTimeMillis() - startTime));
            if (clientPoolIndex == 0) {
                return executeQuerySynchronized(queryParams, System.currentTimeMillis());
            }
        }

        BasicHttpParams httpParams = new BasicHttpParams();
        if (queryParams.maxRedirects != null) {
            httpParams.setIntParameter(ClientPNames.MAX_REDIRECTS, queryParams.maxRedirects);
        } else {
            httpParams.setIntParameter(ClientPNames.MAX_REDIRECTS, 50);
        }
        httpParams.setBooleanParameter(ClientPNames.ALLOW_CIRCULAR_REDIRECTS, true);
        httpParams.setIntParameter(HttpConnectionParams.CONNECTION_TIMEOUT, queryParams.connectionTimeout);
        httpParams.setIntParameter(HttpConnectionParams.SO_TIMEOUT, queryParams.socketTimeout);

        DefaultHttpClient httpClient = httpClientPool[clientPoolIndex];
        httpClient.setRedirectStrategy(queryParams.redirectStrategy);
        if (queryParams.proxy != null) {
            httpClient.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY, queryParams.proxy);
        } else {
            httpClient.getParams().removeParameter(ConnRoutePNames.DEFAULT_PROXY);
        }

        if (queryParams.useAcceptEncodingHeader) {
            httpClient.removeRequestInterceptorByClass(RequestAcceptEncoding.class);
            httpClient.addRequestInterceptor(new RequestAcceptEncoding());
        } else {
            httpClient.removeRequestInterceptorByClass(RequestAcceptEncoding.class);
        }

        // настраиваем кодировку UTF-8, так как внутри работа происходит через класс URI,
        // который использует UTF-8
        HttpProtocolParams.setContentCharset(httpClient.getParams(), HTTP.UTF_8);
        HttpProtocolParams.setHttpElementCharset(httpClient.getParams(), HTTP.UTF_8);

        URI requestUri;
        try {
            requestUri = queryParams.url.toURI();
        } catch (URISyntaxException e) {
            throw new RuntimeException("URL to URI conversion failed: " + queryParams.url.toString(), e);
        }

        HttpRequestBase requestBase;
        switch (queryParams.method) {
            case GET:
                requestBase = new HttpGet(requestUri);
                break;
            case POST:
                requestBase = new HttpPost(requestUri);
                break;
            case HEAD:
                requestBase = new HttpHead(requestUri);
                break;
            case PUT:
                requestBase = new HttpPut(requestUri);
                break;
            default:
                requestBase = new HttpGet(requestUri);
        }

        requestBase.setParams(httpParams);
        if (queryParams.userAgent != null) {
            requestBase.setHeader(HTTP.USER_AGENT, queryParams.userAgent);
        }

        for (Map.Entry<String, String> header : queryParams.headers.entrySet()) {
            requestBase.addHeader(header.getKey(), header.getValue());
        }

        if (queryParams.method == HttpMethod.POST || queryParams.method == HttpMethod.PUT) {
            if (queryParams.entity != null) {
                ((HttpEntityEnclosingRequest) requestBase).setEntity(queryParams.entity);
            }
        }

        long requestTime;
        org.apache.http.HttpResponse response = null;
        InputStream content = null;
        Map<String, Collection<String>> responseHeaders = new HashMap<String, Collection<String>>();
        Collection<String> refreshHeader = null;
        try {
            long startTime = System.currentTimeMillis();
            response = httpClient.execute(requestBase);

            for (Header header : response.getAllHeaders()) {
                if (responseHeaders.get(header.getName()) == null) {
                    responseHeaders.put(header.getName(), new HashSet<String>());
                }
                responseHeaders.get(header.getName()).add(header.getValue());
                if ("refresh".equalsIgnoreCase(header.getName())) {
                    refreshHeader = responseHeaders.get(header.getName());
                }
            }

            if (response.getEntity() != null) {
                try {
                    content = response.getEntity().getContent();
                } catch (IOException e) {
                    if (e.getMessage() != null &&
                            (e.getMessage().contains("GZIP") || e.getMessage().contains("compression"))) {
                        throw new GZipException(e);
                    } else {
                        throw e;
                    }
                }
                final OutputStream copy;
                if (queryParams.contentOutputStream == null) {
                    copy = new ByteArrayOutputStream();
                } else {
                    copy = queryParams.contentOutputStream;
                }
                copyLarge(content, copy, queryParams.maxContentSize);
                try {
                    content.close();
                } catch (IOException e) {
                    // ignore
                }
                if (queryParams.contentOutputStream == null) {
                    content = new ByteArrayInputStream(((ByteArrayOutputStream)copy).toByteArray());
                }
            }

            requestTime = System.currentTimeMillis() - startTime;
        } finally {
            //todo possibly synchronized block is missed here!
            httpClientPoolAvailability[clientPoolIndex] = true;
        }

        if (queryParams.okStatusRequired &&
                (response.getStatusLine().getStatusCode() != HttpStatus.SC_OK)) {
            throw new IOException("Http request returned status code " + response.getStatusLine().getStatusCode());
        }

        if (queryParams.reloadOnRefresh && refreshHeader != null && !refreshHeader.isEmpty()) {
            try {
                log.debug("refresh applied");
                double refreshAfterSeconds = Double.valueOf(refreshHeader.iterator().next());
                long refreshAfterMilliSeconds = (long) (refreshAfterSeconds * 1000.0);
                refreshAfterMilliSeconds = Math.max(
                        Math.min(refreshAfterMilliSeconds, REFRESH_LOW_BOUND), REFRESH_HIGH_BOUND);
                try {
                    Thread.sleep(refreshAfterMilliSeconds);
                } catch (InterruptedException e) {
                    log.error("exception while sleep", e);
                }
                RequestBuilder builder = new RequestBuilder(queryParams).reloadOnRefresh(false);
                return builder.execute();
            } catch (NumberFormatException nfe) {
                log.debug("Number format exception for refresh header " + refreshHeader.iterator().next());
            }
        }

        long contentLength = 0;
        String contentEncoding = null;
        String contentType = null;
        if (response.getEntity() != null) {
            contentLength = response.getEntity().getContentLength();
            Header encodingHeader = response.getEntity().getContentEncoding();
            if (encodingHeader != null) {
                contentEncoding = encodingHeader.getValue();
            }
            Header contentTypeHeader = response.getEntity().getContentType();
            if (contentTypeHeader != null) {
                contentType = contentTypeHeader.getValue();
            }
        }

        return new HttpResponse(response.getStatusLine().getStatusCode(), requestTime, content, contentLength,
                contentEncoding, contentType, responseHeaders);
    }

    public static long copyLarge(InputStream input, OutputStream output, Long maxSize)
            throws IOException {
        if (maxSize == null) {
            return IOUtils.copyLarge(input, output);
        }
        byte[] buffer = new byte[4096];
        long count = 0;
        int n = 0;
        while (-1 != (n = input.read(buffer))) {
            output.write(buffer, 0, n);
            count += n;
            if (count > maxSize) {
                throw new MaxLengthExceededException("Max content size exceeded");
            }
        }
        return count;
    }


    private static SSLSocketFactory getSSLSocketFactory() {
        try {
            X509TrustManager trustManager = new X509TrustManager() {    // trust manager which trusts everything
                @Override
                public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                }

                @Override
                public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
                }

                @Override
                public X509Certificate[] getAcceptedIssuers() {
                    return new X509Certificate[0];
                }
            };

            SSLContext sslcontext = SSLContext.getInstance("TLS");
            sslcontext.init(null, new TrustManager[]{trustManager}, null);

            SSLSocketFactory sslSocketFactory = new SSLSocketFactory(sslcontext);
            sslSocketFactory.setHostnameVerifier(SSLSocketFactory.ALLOW_ALL_HOSTNAME_VERIFIER);
            return sslSocketFactory;
        } catch (NoSuchAlgorithmException e) {
            throw new AssertionError(e);
        } catch (KeyManagementException e) {
            log.error("Error creating SSL socket factory with custom trust manager, using standard one.", e);
            return SSLSocketFactory.getSocketFactory();
        }
    }

    private static synchronized int poolGetAndLock() {
        //todo we're using POOL_CLIENTS-1 clients only
        for (int i = 1; i < POOL_CLIENTS; i++) {
            if (httpClientPoolAvailability[i]) {
                httpClientPoolAvailability[i] = false;
                return i;
            }
        }

        return 0;
    }
}
