/*
 * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS HEADER.
 *
 * Copyright (c) 2010-2013 Oracle and/or its affiliates. All rights reserved.
 *
 * The contents of this file are subject to the terms of either the GNU
 * General Public License Version 2 only ("GPL") or the Common Development
 * and Distribution License("CDDL") (collectively, the "License").  You
 * may not use this file except in compliance with the License.  You can
 * obtain a copy of the License at
 * http://glassfish.java.net/public/CDDL+GPL_1_1.html
 * or packager/legal/LICENSE.txt.  See the License for the specific
 * language governing permissions and limitations under the License.
 *
 * When distributing the software, include this License Header Notice in each
 * file and include the License file at packager/legal/LICENSE.txt.
 *
 * GPL Classpath Exception:
 * Oracle designates this particular file as subject to the "Classpath"
 * exception as provided by Oracle in the GPL Version 2 section of the License
 * file that accompanied this code.
 *
 * Modifications:
 * If applicable, add the following below the License Header, with the fields
 * enclosed by brackets [] replaced by your own identifying information:
 * "Portions Copyright [year] [name of copyright owner]"
 *
 * Contributor(s):
 * If you wish your version of this file to be governed by only the CDDL or
 * only the GPL Version 2, indicate your decision by adding "[Contributor]
 * elects to include this software in this distribution under the [CDDL or GPL
 * Version 2] license."  If you don't indicate a single choice of license, a
 * recipient has the option to distribute your version of this file under
 * either the CDDL, the GPL Version 2 or to extend the choice of license to
 * its licensees as provided above.  However, if you add GPL Version 2 code
 * and therefore, elected the GPL Version 2 license, then the option applies
 * only if the new code is made subject to such option by the copyright
 * holder.
 */

package com.sun.jersey.client.non.blocking;

import com.ning.http.client.AsyncHttpClient;
import com.ning.http.client.AsyncHttpClientConfig;
import com.ning.http.client.Cookie;
import com.ning.http.client.FluentCaseInsensitiveStringsMap;
import com.ning.http.client.ProxyServer;
import com.ning.http.client.Realm;
import com.ning.http.client.Request;
import com.ning.http.client.RequestBuilder;
import com.ning.http.client.Response;
import com.ning.http.client.filter.RequestFilter;
import com.ning.http.client.filter.ResponseFilter;
import com.ning.http.client.providers.grizzly.GrizzlyAsyncHttpProvider;
import com.sun.jersey.api.client.ClientHandlerException;
import com.sun.jersey.api.client.ClientRequest;
import com.sun.jersey.api.client.ClientResponse;
import com.sun.jersey.api.client.TerminatingClientHandler;
import com.sun.jersey.api.client.config.ClientConfig;
import com.sun.jersey.client.non.blocking.config.NonBlockingClientConfig;
import com.sun.jersey.client.urlconnection.HTTPSProperties;
import com.sun.jersey.core.header.InBoundHeaders;
import com.sun.jersey.core.util.ReaderWriter;

import javax.net.ssl.HostnameVerifier;
import javax.ws.rs.core.MultivaluedMap;
import java.io.BufferedInputStream;
import java.io.ByteArrayInputStream;
import java.io.Closeable;
import java.io.FilterInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

/**
 * A root handler with Ning Async HTTP client acting as a backend.
 * <p>
 * Client operations are thread safe, the HTTP connection may
 * be shared between different threads.
 * <p>
 * If a response entity is obtained that is an instance of {@link Closeable}
 * then the instance MUST be closed after processing the entity to release
 * connection-based resources.
 * <p>
 * If a {@link ClientResponse} is obtained and an entity is not read from the
 * response then {@link ClientResponse#close() } MUST be called after processing
 * the response to release connection-based resources.
 * <p>
 * The following methods are currently supported: HEAD, GET, POST, PUT, DELETE
 * and OPTIONS.
 * <p>
 * Chunked transfer encoding can be enabled or disabled but configuration of
 * the chunked encoding size is not possible. If the 
 * {@link ClientConfig#PROPERTY_CHUNKED_ENCODING_SIZE} property is set
 * to a non-null value then chunked transfer encoding is enabled.
 *
 * @author jorgeluisw@mac.com
 * @author Paul.Sandoz@Sun.Com
 * @author pavel.bucek@oracle.com
 */
public final class NonBlockingClientHandler extends TerminatingClientHandler {

    private final AsyncHttpClient client;
    private final ExecutorService executorService;
    private final ClientConfig clientConfig;

    private final ThreadLocal<Collection<Cookie>> cookieStore = new ThreadLocal<Collection<Cookie>>() {
        @Override
        protected Collection<Cookie> initialValue() {
            return new HashSet<Cookie>();
        }
    };

    public NonBlockingClientHandler(final ClientConfig cc) {
        AsyncHttpClientConfig.Builder builder = new AsyncHttpClientConfig.Builder();

        if(cc != null) {
            Object executorService = cc.getProperty(NonBlockingClientConfig.PROPERTY_EXECUTOR_SERVICE);
            if(executorService != null && (executorService instanceof ExecutorService)) {
                builder = builder.setExecutorService((ExecutorService)executorService);
                this.executorService = (ExecutorService)executorService;
            } else {
                final Object threadpoolSize = cc.getProperties().get(ClientConfig.PROPERTY_THREADPOOL_SIZE);

                if(threadpoolSize != null && threadpoolSize instanceof Integer && (Integer)threadpoolSize > 0) {
                    this.executorService = Executors.newFixedThreadPool((Integer) threadpoolSize);
                } else {
                    this.executorService = Executors.newCachedThreadPool();
                }
                builder = builder.setExecutorService(this.executorService);
            }

            Integer timeout = (Integer)cc.getProperties().get(ClientConfig.PROPERTY_CONNECT_TIMEOUT);
            if(timeout != null)
                builder = builder.setConnectionTimeoutInMs(timeout);

            Object username = cc.getProperties().get(NonBlockingClientConfig.PROPERTY_AUTH_USERNAME);
            Object password = cc.getProperties().get(NonBlockingClientConfig.PROPERTY_AUTH_PASSWORD);

            if((username != null) && (password != null)) {
                boolean preemptiveAuth = cc.getPropertyAsFeature(NonBlockingClientConfig.PROPERTY_AUTH_PREEMPTIVE);
                Object scheme = cc.getProperties().get(NonBlockingClientConfig.PROPERTY_AUTH_SCHEME);

                Realm.AuthScheme authScheme = Realm.AuthScheme.NONE;
                if(scheme != null) {
                    if(scheme.equals("DIGEST")) {
                        authScheme = Realm.AuthScheme.DIGEST;
                    } else if(scheme.equals("KERBEROS")) {
                        authScheme = Realm.AuthScheme.KERBEROS;
                    } else if(scheme.equals("NTLM")) {
                        authScheme = Realm.AuthScheme.NTLM;
                    } else if(scheme.equals("SPNEGO")) {
                        authScheme = Realm.AuthScheme.SPNEGO;
                    }
                } else {
                    authScheme = Realm.AuthScheme.BASIC;
                }

                Realm realm = new Realm.RealmBuilder()
                        .setPrincipal(username.toString())
                        .setPassword(password.toString())
                        .setUsePreemptiveAuth(preemptiveAuth)
                        .setScheme(authScheme).build();

                builder = builder.setRealm(realm);
            }

            Object httpsPropertiesProperty = cc.getProperties().get(HTTPSProperties.PROPERTY_HTTPS_PROPERTIES);
            if(httpsPropertiesProperty != null && httpsPropertiesProperty instanceof HTTPSProperties) {
                HTTPSProperties httpsProperties = (HTTPSProperties)httpsPropertiesProperty;
                final HostnameVerifier hostnameVerifier = httpsProperties.getHostnameVerifier();
                if(hostnameVerifier != null) {
                    builder = builder.setHostnameVerifier(hostnameVerifier);
                }
                builder = builder.setSSLContext(httpsProperties.getSSLContext());
            }

            Object requestFilters = cc.getProperties().get(NonBlockingClientConfig.PROPERTY_REQUEST_FILTERS);
            if(requestFilters != null) {
                if(requestFilters instanceof RequestFilter) {
                    builder.addRequestFilter((RequestFilter) requestFilters);
                } else if(requestFilters instanceof List) {
                    List<?> requestFilterList = (List)requestFilters;
                    for (ListIterator iterator = requestFilterList.listIterator(requestFilterList.size()); iterator.hasPrevious();) {
                        final Object listElement = iterator.previous();
                        if(listElement instanceof RequestFilter) {
                            builder.addRequestFilter((RequestFilter)listElement);
                        }
                    }
                }
            }

            Object responseFilters = cc.getProperties().get(NonBlockingClientConfig.PROPERTY_RESPONSE_FILTERS);
            if(responseFilters != null) {
                if(responseFilters instanceof ResponseFilter) {
                    builder.addResponseFilter((ResponseFilter) responseFilters);
                } else if(responseFilters instanceof List) {
                    List<?> responseFiltersList = (List)responseFilters;
                    for (ListIterator iterator = responseFiltersList.listIterator(responseFiltersList.size()); iterator.hasPrevious();) {
                        final Object listElement = iterator.previous();
                        if(listElement instanceof ResponseFilter) {
                            builder.addResponseFilter((ResponseFilter) listElement);
                        }
                    }
                }
            }

        } else {
            this.executorService = Executors.newCachedThreadPool();
            builder.setExecutorService(this.executorService);
        }
        final AsyncHttpClientConfig config = builder.build();
        this.client = new AsyncHttpClient(new GrizzlyAsyncHttpProvider(config), config);
        this.clientConfig = cc;
    }

    public AsyncHttpClient getHttpClient() {
        return client;
    }

    public final ExecutorService getExecutorService() {
        return executorService;
    }

    public ClientResponse handle(final ClientRequest cr)
            throws ClientHandlerException {

        final Request request = getRequest(cr);

        try {
            Future<Response> response;

            response = getHttpClient().executeRequest(request);

            return getClientResponse(response.get());
        } catch (Exception e) {
            throw new ClientHandlerException(e);
        }
    }

    ClientResponse getClientResponse(Response response) {

        if(this.clientConfig != null && (!this.clientConfig.getPropertyAsFeature(NonBlockingClientConfig.PROPERTY_DISABLE_COOKIES)))
            cookieStore.get().addAll(response.getCookies());

        try {
            ClientResponse r = new ClientResponse(response.getStatusCode(),
                    getInBoundHeaders(response),
                    new HttpClientResponseInputStream(response),
                    getMessageBodyWorkers());
            if (!r.hasEntity()) {
                r.bufferEntity();
                r.close();
            }

            return r;
        } catch (Exception e) {
            throw new ClientHandlerException(e);
        }
    }

    Request getRequest(final ClientRequest cr) {
        final String strMethod = cr.getMethod();
        final URI uri = cr.getURI();

        RequestBuilder builder = new RequestBuilder(strMethod).setUrl(uri.toString());

        final Request.EntityWriter entity = getHttpEntity(cr);

        if(entity != null) {
            builder = builder.setBody(entity);
        }

        ProxyServer proxyServer = createProxyServer(cr);
        if(proxyServer != null)
            builder.setProxyServer(proxyServer);

        if(this.clientConfig != null) {
            if(!this.clientConfig.getPropertyAsFeature(NonBlockingClientConfig.PROPERTY_DISABLE_COOKIES)) {
                for(Cookie cookie : cookieStore.get())
                    builder = builder.addCookie(cookie);
                cookieStore.remove();
            }

            if(this.clientConfig.getPropertyAsFeature(ClientConfig.PROPERTY_FOLLOW_REDIRECTS))
                builder = builder.setFollowRedirects(true);
            else
                builder = builder.setFollowRedirects(false);
        }

        Request request = builder.build();

//        /* extremely ugly, unnecessary and inefficient. Unfortunately it is needed
//           because client side providers which modifies header values when entity is
//           being written. Ning Client writes is too late, so header is ClientRequest is
//           not yet changed and not propagated into Nings Request.
//           Question is - should we support this case? Yes - for now.
//        */
//        try {
//            if(request.getEntityWriter() != null)
//                request.getEntityWriter().writeEntity(new OutputStream() {
//                    @Override
//                    public void write(int i) throws IOException {
//                    }
//                });
//        } catch (IOException ignored) {
//        }

        writeOutBoundHeaders(cr.getHeaders(), request);

        return request;
    }

    private ProxyServer createProxyServer(ClientRequest cr) {
        Object proxyHost = cr.getProperties().get(NonBlockingClientConfig.PROPERTY_PROXY_HOST);
        if(proxyHost != null) {
            ProxyServerBuilder proxyBuilder = new ProxyServerBuilder((String) proxyHost);

            Integer proxyPort = (Integer)cr.getProperties().get(NonBlockingClientConfig.PROPERTY_PROXY_PORT);
            if(proxyPort != null)
                proxyBuilder.setPort(proxyPort);

            String proxyUser = (String)cr.getProperties().get(NonBlockingClientConfig.PROPERTY_PROXY_USERNAME);
            if(proxyUser != null) {
                String proxyPass = (String)cr.getProperties().get(NonBlockingClientConfig.PROPERTY_PROXY_PASSWORD);
                if(proxyPass != null) {
                    proxyBuilder.setUsername(proxyUser);
                    proxyBuilder.setPassword(proxyPass);
                }
            }

            String proxyProto = (String)cr.getProperties().get(NonBlockingClientConfig.PROPERTY_PROXY_PROTOCOL);
            if(proxyProto != null) {
                if(proxyProto.equals("HTTP")) {
                    proxyBuilder.setProtocol(ProxyServer.Protocol.HTTP);
                } else if(proxyProto.equals("HTTPS")) {
                    proxyBuilder.setProtocol(ProxyServer.Protocol.HTTPS);
                } else if(proxyProto.equals("NTLM")) {
                    proxyBuilder.setProtocol(ProxyServer.Protocol.NTLM);
                } else if(proxyProto.equals("SPNEGO")) {
                    proxyBuilder.setProtocol(ProxyServer.Protocol.SPNEGO);
                } else if(proxyProto.equals("KERBEROS")) {
                    proxyBuilder.setProtocol(ProxyServer.Protocol.KERBEROS);
                }
            }
            return proxyBuilder.build();
        } else {
            return null;
        }
    }

    private Request.EntityWriter getHttpEntity(final ClientRequest cr) {
        final Object entity = cr.getEntity();

        if(entity == null)
            return null;

        final RequestEntityWriter requestEntityWriter = getRequestEntityWriter(cr);

        return new Request.EntityWriter() {
            @Override
            public void writeEntity(OutputStream out) throws IOException {
                requestEntityWriter.writeRequestEntity(out);
            }
        };
    }

    protected static void writeOutBoundHeaders(final MultivaluedMap<String, Object> headers, final Request request) {
        for (Map.Entry<String, List<Object>> e : headers.entrySet()) {
            List<Object> vs = e.getValue();
            if (vs.size() == 1) {
                request.getHeaders().add(e.getKey(), ClientRequest.getHeaderValue(vs.get(0)));
            } else {
                StringBuilder b = new StringBuilder();
                for (Object v : e.getValue()) {
                    if (b.length() > 0) {
                        b.append(',');
                    }
                    b.append(ClientRequest.getHeaderValue(v));
                }
                request.getHeaders().add(e.getKey(), b.toString());
            }
        }
    }

    private InBoundHeaders getInBoundHeaders(final Response response) throws ExecutionException, InterruptedException {
        final InBoundHeaders headers = new InBoundHeaders();
        FluentCaseInsensitiveStringsMap responseHeaders = response.getHeaders();

        for (FluentCaseInsensitiveStringsMap.Entry<String, List<String>> header : responseHeaders) {
            headers.put(header.getKey(), header.getValue());
        }

        return headers;
    }

    static final class HttpClientResponseInputStream extends FilterInputStream {

        HttpClientResponseInputStream(Response response) throws IOException, ExecutionException, InterruptedException {
            super(getInputStream(response));
        }

        @Override
        public void close()
                throws IOException {
            super.close();
        }
    }

    private static InputStream getInputStream(final Response response) throws IOException, ExecutionException, InterruptedException {
        if(!response.hasResponseBody()) {
            return new ByteArrayInputStream(new byte[0]);
        } else {
            final InputStream i = response.getResponseBodyAsStream();
            if(i.markSupported())
                return i;
            return new BufferedInputStream(i, ReaderWriter.BUFFER_SIZE);
        }
    }

    private static class ProxyServerBuilder {
        private String host = null;
        private Integer port = null;
        private String username = null;
        private String password = null;
        private ProxyServer.Protocol protocol = null;

        public ProxyServerBuilder(String host) {
            this.host = host;
        }

        public ProxyServerBuilder setPort(int port) {
            this.port = port;
            return this;
        }

        public ProxyServerBuilder setUsername(String username) {
            this.username = username;
            return this;
        }

        public ProxyServerBuilder setPassword(String password) {
            this.password = password;
            return this;
        }

        public ProxyServerBuilder setProtocol(ProxyServer.Protocol protocol) {
            this.protocol = protocol;
            return this;
        }

        public ProxyServer build() {
            return new ProxyServer(protocol, host, port, username, password);
        }
    }
}
