/*
 * RHQ Management Platform
 * Copyright (C) 2005-2017 Red Hat, Inc.
 * All rights reserved.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation version 2 of the License.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA
 */

package org.rhq.modules.plugins.wildfly10;

import java.io.BufferedOutputStream;
import java.io.Closeable;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URI;
import java.net.URISyntaxException;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URIBuilder;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.conn.scheme.SchemeRegistry;
import org.apache.http.entity.mime.MultipartEntity;
import org.apache.http.entity.mime.content.FileBody;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.BasicClientConnectionManager;
import org.apache.http.params.HttpConnectionParams;
import org.apache.http.params.HttpParams;
import org.apache.http.util.EntityUtils;
import org.codehaus.jackson.JsonNode;
import org.codehaus.jackson.map.ObjectMapper;

import org.rhq.modules.plugins.wildfly10.helper.ServerPluginConfiguration;

/**
 * Connection for uploading of content.
 * 
 * This class needs to cache the content to be uploaded. Users of this class should:
 * <ol>
 * <li>Call {@link #getOutputStream()} an write their content to the returned {@link OutputStream}</li>
 * <li>Call {@link #finishUpload()} to actually upload the content</li>
 * </ol>
 * 
 * As instances of this class held some resources it is the caller responsibility to call {@link #cancelUpload()} 
 * instead of {@link #finishUpload()} if, for example, an {@link IOException} occured while writing their content 
 * to the {@link OutputStream}.
 * 
 * Original code taken from https://github.com/jbossas/jboss-as/blob/master/testsuite/smoke/src/test/java/org/jboss/as/test/surefire/servermodule/HttpDeploymentUploadUnitTestCase.java
 *
 * @author Jonathan Pearlin (of the original code)
 * @author Heiko W. Rupp
 * @author Thomas Segismont
 */
public class ASUploadConnection {
    private static final Log LOG = LogFactory.getLog(ASUploadConnection.class);

    private static final int SOCKET_CONNECTION_TIMEOUT = 30 * 1000; // 30sec
    private static final int SOCKET_READ_TIMEOUT = 10 * 60 * 1000; // 10 minutes
    private static final String TRIGGER_AUTH_URI = ASConnection.MANAGEMENT_URI;
    private static final String UPLOAD_URI = ASConnection.MANAGEMENT_URI + "/add-content";
    private static final int FILE_POST_MAX_LOGGABLE_RESPONSE_LENGTH = 1024 * 2; // 2k max
    private static final String EMPTY_JSON_TREE = "{}";
    private static final String JSON_NODE_FAILURE_DESCRIPTION = "failure-description";
    private static final String JSON_NODE_FAILURE_DESCRIPTION_VALUE_DEFAULT = "FailureDescription: -input was null-";
    private static final String JSON_NODE_OUTCOME = "outcome";
    private static final String JSON_NODE_OUTCOME_VALUE_FAILED = "failed";
    private static final String SYSTEM_LINE_SEPARATOR = System.getProperty("line.separator");

    private final ASConnectionParams asConnectionParams;
    private final int timeout;
    private final URI triggerAuthUri;
    private final URI uploadUri;
    private final UsernamePasswordCredentials credentials;
    private String filename;
    private File cacheFile;
    private BufferedOutputStream cacheOutputStream;

    /**
     * @deprecated as of 4.6. This class is not reusable so there is no reason not to provide the filename to the 
     * constructor. Use {@link #ASUploadConnection(ASConnectionParams, String)}  instead.
     */
    @Deprecated
    public ASUploadConnection(String host, int port, String user, String password) {
        this(new ASConnectionParamsBuilder() //
            .setHost(host) //
            .setPort(port) //
            .setUsername(user) //
            .setPassword(password) //
            .createASConnectionParams(), null);
    }

    /**
     * @deprecated as of RHQ 4.10. Use {@link #ASUploadConnection(ASConnectionParams, String)} instead.
     */
    @Deprecated
    public ASUploadConnection(String host, int port, String user, String password, String fileName) {
        this(new ASConnectionParamsBuilder() //
            .setHost(host) //
            .setPort(port) //
            .setUsername(user) //
            .setPassword(password) //
            .createASConnectionParams(), fileName);
    }

    /**
     * @deprecated as of 4.6. This class is not reusable so there is no reason not to provide the filename to the 
     * constructor. Use {@link #ASUploadConnection(ASConnection, String)} instead.
     */
    @Deprecated
    public ASUploadConnection(ASConnection asConnection) {
        this(asConnection.getAsConnectionParams(), null);
    }

    /**
     * @param asConnection the object which will provide the {@link ASConnectionParams}
     * @param fileName
     * @see #ASUploadConnection(ASConnectionParams, String)
     */
    public ASUploadConnection(ASConnection asConnection, String fileName) {
        this(asConnection.getAsConnectionParams(), fileName);
    }

    /**
     * @param asConnection the object which will provide the {@link ASConnectionParams}
     * @param fileName
     * @see #ASUploadConnection(ASConnectionParams, String)
     */
    public ASUploadConnection(ASConnection asConnection, String fileName, Integer timeoutMilliseconds) {
        this(asConnection.getAsConnectionParams(), fileName, timeoutMilliseconds);
    }

    /**
     * Creates a new {@link ASUploadConnection} for a remote http management interface.
     *
     * It's the responsibility of the caller to make sure either {@link #finishUpload()} or {@link #cancelUpload()}
     * will be called to free resources this class helds.
     *
     * @param params
     */
    public ASUploadConnection(ASConnectionParams params, String filename) {
        this(params, filename, null);
    }

    /**
     * Creates a new {@link ASUploadConnection} for a remote http management interface.
     *
     * It's the responsibility of the caller to make sure either {@link #finishUpload()} or {@link #cancelUpload()}
     * will be called to free resources this class helds.
     *
     * @param params
     */
    public ASUploadConnection(ASConnectionParams params, String filename, Integer timeoutMilliseconds) {
        asConnectionParams = params;
        if (asConnectionParams.getHost() == null) {
            throw new IllegalArgumentException("Management host cannot be null.");
        }
        if (asConnectionParams.getPort() <= 0 || asConnectionParams.getPort() > 65535) {
            throw new IllegalArgumentException("Invalid port: " + asConnectionParams.getPort());
        }
        this.filename = filename;
        if(timeoutMilliseconds != null && timeoutMilliseconds.intValue() > 0) {
            timeout = timeoutMilliseconds;
        } else {
            timeout = SOCKET_READ_TIMEOUT;
        }
        triggerAuthUri = buildTriggerAuthUri();
        uploadUri = buildUploadUri();
        if (asConnectionParams.getUsername() != null && asConnectionParams.getPassword() != null) {
            credentials = new UsernamePasswordCredentials(asConnectionParams.getUsername(),
                    asConnectionParams.getPassword());
        } else {
            credentials = null;
        }
    }

    private URI buildTriggerAuthUri() {
        try {
            return new URIBuilder() //
                .setScheme(asConnectionParams.isSecure() ? ASConnection.HTTPS_SCHEME : ASConnection.HTTP_SCHEME) //
                .setHost(asConnectionParams.getHost()) //
                .setPort(asConnectionParams.getPort()) //
                .setPath(TRIGGER_AUTH_URI) //
                .build();
        } catch (URISyntaxException e) {
            throw new RuntimeException("Could not build auth trigger URI: " + e.getMessage(), e);
        }
    }

    private URI buildUploadUri() {
        try {
            return new URIBuilder() //
                .setScheme(asConnectionParams.isSecure() ? ASConnection.HTTPS_SCHEME : ASConnection.HTTP_SCHEME) //
                .setHost(asConnectionParams.getHost()) //
                .setPort(asConnectionParams.getPort()) //
                .setPath(UPLOAD_URI) //
                .build();
        } catch (URISyntaxException e) {
            throw new RuntimeException("Could not build upload URI: " + e.getMessage(), e);
        }
    }

    /**
     * @deprecated as of RHQ 4.10. Use {@link #ASUploadConnection(ASConnectionParams, String)} instead.
     */
    @Deprecated
    public static ASUploadConnection newInstanceForServerPluginConfiguration(ServerPluginConfiguration pluginConfig,
        String fileName) {
        return new ASUploadConnection(ASConnectionParams.createFrom(pluginConfig), fileName);
    }

    /**
     * @deprecated as of 4.6. Instances of this class should be created with fileName supplied to the constructor. 
     * If the caller does that there is no reason for late initialization of fileName. Then use 
     * {@link #getOutputStream()} instead.  
     */
    @Deprecated
    public OutputStream getOutputStream(String fileName) {
        filename = fileName;
        return getOutputStream();
    }

    /**
     * Gives an outpustream where callers should write the content which will be uploaded to AS7 
     * when {@link #finishUpload()} will be called.
     * 
     * @return an {@link OutputStream} or null if it could not be created.
     */
    public OutputStream getOutputStream() {
        try {
            cacheFile = File.createTempFile(getClass().getSimpleName(), ".cache");
            cacheOutputStream = new BufferedOutputStream(new FileOutputStream(cacheFile));
            return cacheOutputStream;
        } catch (IOException e) {
            LOG.error("Could not create outputstream for " + filename, e);
        }
        return null;
    }

    /**
     * To be called instead of {@link #finishUpload()} if one doesn't want to upload the cached content.
     * 
     * It's important to call this method if not actually uploading as it frees resources this class helds. 
     */
    public void cancelUpload() {
        closeQuietly(cacheOutputStream);
        deleteCacheFile();
    }

    /**
     * Triggers the real upload to the AS7 instance. At this point the caller should have written 
     * the content in the {@link OutputStream} given by {@link #getOutputStream()}.
     * 
     * @return a {@link JsonNode} instance read from the upload response body or null if something went wrong.
     */
    public JsonNode finishUpload() {
        if (filename == null) {
            // At this point the fileName should have been set whether at instanciation or in #getOutputStream(String)
            throw new IllegalStateException("Upload fileName is null");
        }

        closeQuietly(cacheOutputStream);

        SchemeRegistry schemeRegistry = new SchemeRegistryBuilder(asConnectionParams).buildSchemeRegistry();
        ClientConnectionManager httpConnectionManager = new BasicClientConnectionManager(schemeRegistry);
        DefaultHttpClient httpClient = new DefaultHttpClient(httpConnectionManager);
        HttpParams httpParams = httpClient.getParams();
        HttpConnectionParams.setConnectionTimeout(httpParams, SOCKET_CONNECTION_TIMEOUT);
        HttpConnectionParams.setSoTimeout(httpParams, timeout);

        if (credentials != null && !asConnectionParams.isClientcertAuthentication()) {
            httpClient.getCredentialsProvider().setCredentials(
                new AuthScope(asConnectionParams.getHost(), asConnectionParams.getPort()), credentials);

            // If credentials were provided, we will first send a GET request to trigger the authentication challenge
            // This allows to send the potentially big file only once to the server
            // The typical resulting http exchange would be:
            //
            // GET without auth <- 401 (start auth challenge : the server will name the realm and the scheme)
            // GET with auth <- 200
            // POST big file
            //
            // Note this only works because we use SimpleHttpConnectionManager which maintains only one HttpConnection
            //
            // A better way to avoid uploading a big file twice would be to use the header "Expect: Continue"
            // Unfortunately AS7 replies "100 Continue" even if authentication headers are not present yet
            //
            // There is no need to trigger digest authentication when client certification authentication is used

            HttpGet triggerAuthRequest = new HttpGet(triggerAuthUri);
            try {
                // Send GET request in order to trigger authentication
                // We don't check response code because we're not already uploading the file
                httpClient.execute(triggerAuthRequest);
            } catch (Exception ignore) {
                // We don't stop trying upload if triggerAuthRequest raises exception
                // See comment above
            } finally {
                triggerAuthRequest.abort();
            }
        }

        HttpPost uploadRequest = new HttpPost(uploadUri);
        try {

            // Now upload file with multipart POST request
            MultipartEntity multipartEntity = new MultipartEntity();
            multipartEntity.addPart(filename, new FileBody(cacheFile));
            uploadRequest.setEntity(multipartEntity);
            HttpResponse uploadResponse = httpClient.execute(uploadRequest);
            if (uploadResponse.getStatusLine().getStatusCode() != HttpStatus.SC_OK) {
                logUploadDoesNotEndWithHttpOkStatus(uploadResponse);
                return null;
            }

            ObjectMapper objectMapper = new ObjectMapper();
            InputStream responseBodyAsStream = uploadResponse.getEntity().getContent();
            if (responseBodyAsStream == null) {
                LOG.warn("POST request has no response body");
                return objectMapper.readTree(EMPTY_JSON_TREE);
            }
            return objectMapper.readTree(responseBodyAsStream);

        } catch (Exception e) {
            LOG.error(e);
            return null;
        } finally {
            // Release httpclient resources
            uploadRequest.abort();
            httpConnectionManager.shutdown();
            // Delete cache file
            deleteCacheFile();
        }
    }

    /**
     * Inspects the supplied {@link JsonNode} instance and returns the json 'failure-description' node value as text.
     * 
     * @param jsonNode
     * @return
     */
    public static String getFailureDescription(JsonNode jsonNode) {
        if (jsonNode == null) {
            return JSON_NODE_FAILURE_DESCRIPTION_VALUE_DEFAULT;
        }
        JsonNode node = jsonNode.findValue(JSON_NODE_FAILURE_DESCRIPTION);
        if (node == null) {
            return JSON_NODE_FAILURE_DESCRIPTION_VALUE_DEFAULT;
        }
        return node.getValueAsText();
    }

    /**
     * Inspects the supplied {@link JsonNode} instance to determine if it represents an error outcome.
     * 
     * @param jsonNode
     * @return
     */
    public static boolean isErrorReply(JsonNode jsonNode) {
        if (jsonNode == null) {
            return true;
        }

        if (jsonNode.has(JSON_NODE_OUTCOME)) {
            String outcome = null;
            try {
                JsonNode outcomeNode = jsonNode.findValue(JSON_NODE_OUTCOME);
                outcome = outcomeNode.getTextValue();
                if (outcome.equals(JSON_NODE_OUTCOME_VALUE_FAILED)) {
                    return true;
                }
            } catch (Exception e) {
                LOG.error(e);
                return true;
            }
        }
        return false;
    }

    private void logUploadDoesNotEndWithHttpOkStatus(HttpResponse uploadResponse) {
        StringBuilder logMessageBuilder = new StringBuilder("File upload failed: ").append(ASConnection
            .statusAsString(uploadResponse.getStatusLine()));
        // If it's sure there is a response body and it's not too long 
        if (uploadResponse.getEntity().getContentLength() > 0
            && uploadResponse.getEntity().getContentLength() < FILE_POST_MAX_LOGGABLE_RESPONSE_LENGTH) {
            try {
                // It is safe to get response body as String as we know the body is not too long
                String responseBodyAsString = EntityUtils.toString(uploadResponse.getEntity());
                logMessageBuilder.append(SYSTEM_LINE_SEPARATOR).append(responseBodyAsString);
            } catch (IOException ignore) {
                // If we can't get the response body, we'll just not log it
            }
        }
        LOG.warn(logMessageBuilder.toString());
    }

    private void closeQuietly(final Closeable closeable) {
        if (closeable != null) {
            try {
                closeable.close();
            } catch (final IOException ignore) {
            }
        }
    }

    private void deleteCacheFile() {
        if (cacheFile != null) {
            cacheFile.delete();
        }
    }

    /**
     * Get the currently active upload timeout
     * @return timeout in seconds
     * @deprecated there is no reason to expose this attribute
     */
    @Deprecated
    public int getTimeout() {
        return timeout;
    }

    /**
     * Set upload timeout in seconds.
     * @param timeout upload timeout in seconds
     * @deprecated there is no reason to expose this attribute
     */
    @Deprecated
    public void setTimeout(int timeout) {
    }
}
