/**
 * The contents of this file are subject to the license and copyright
 * detailed in the LICENSE and NOTICE files at the root of the source
 * tree and available online at
 *
 * http://www.dspace.org/license/
 */
package org.purl.sword.client;

import java.io.BufferedReader;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.UnsupportedEncodingException;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.NoSuchAlgorithmException;
import java.util.Properties;

import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.HttpStatus;
import org.apache.http.StatusLine;
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.conn.params.ConnRoutePNames;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.FileEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.params.HttpParams;
import org.apache.logging.log4j.Logger;
import org.purl.sword.base.ChecksumUtils;
import org.purl.sword.base.DepositResponse;
import org.purl.sword.base.HttpHeaders;
import org.purl.sword.base.ServiceDocument;
import org.purl.sword.base.SwordValidationInfo;
import org.purl.sword.base.UnmarshallException;

/**
 * This is an example Client implementation to demonstrate how to connect to a
 * SWORD server. The client supports BASIC HTTP Authentication. This can be
 * initialised by setting a username and password.
 *
 * @author Neil Taylor
 */
public class Client implements SWORDClient {
    /**
     * The status field for the response code from the recent network access.
     */
    private Status status;

    /**
     * The name of the server to contact.
     */
    private String server;

    /**
     * The port number for the server.
     */
    private int port;

    /**
     * Specifies if the network access should use HTTP authentication.
     */
    private boolean doAuthentication;

    /**
     * The username to use for Basic Authentication.
     */
    private String username;

    /**
     * User password that is to be used.
     */
    private String password;

    /**
     * The userAgent to identify this application.
     */
    private String userAgent;

    /**
     * The client that is used to send data to the specified server.
     */
    private final DefaultHttpClient client;

    /**
     * The default connection timeout. This can be modified by using the
     * setSocketTimeout method.
     */
    public static final int DEFAULT_TIMEOUT = 20000;

    /**
     * Logger.
     */
    private static final Logger log = org.apache.logging.log4j.LogManager.getLogger(Client.class);

    /**
     * Create a new Client. The client will not use authentication by default.
     */
    public Client() {
        client = new DefaultHttpClient();
        HttpParams params = client.getParams();
        params.setParameter("http.socket.timeout",
                            Integer.valueOf(DEFAULT_TIMEOUT));
        HttpHost proxyHost = (HttpHost) params
            .getParameter(ConnRoutePNames.DEFAULT_PROXY); // XXX does this really work?
        log.debug("proxy host: " + proxyHost.getHostName());
        log.debug("proxy port: " + proxyHost.getPort());
        doAuthentication = false;
    }

    /**
     * Initialise the server that will be used to send the network access.
     *
     * @param server server address/hostname
     * @param port   server port
     */
    public void setServer(String server, int port) {
        this.server = server;
        this.port = port;
    }

    /**
     * Set the user credentials that will be used when making the access to the
     * server.
     *
     * @param username The username.
     * @param password The password.
     */
    public void setCredentials(String username, String password) {
        this.username = username;
        this.password = password;
        doAuthentication = true;
    }

    /**
     * Set the basic credentials. You must have previously set the server and
     * port using setServer.
     *
     * @param username The username.
     * @param password The password.
     */
    private void setBasicCredentials(String username, String password) {
        log.debug("server: " + server + " port: " + port + " u: '" + username
                      + "' p '" + password + "'");
        client.getCredentialsProvider().setCredentials(new AuthScope(server, port),
                                                       new UsernamePasswordCredentials(username, password));
    }

    /**
     * Set a proxy that should be used by the client when trying to access the
     * server. If this is not set, the client will attempt to make a direct
     * direct connection to the server. The port is set to 80.
     *
     * @param host The hostname.
     */
    public void setProxy(String host) {
        setProxy(host, 80);
    }

    /**
     * Set a proxy that should be used by the client when trying to access the
     * server. If this is not set, the client will attempt to make a direct
     * direct connection to the server.
     *
     * @param host The name of the host.
     * @param port The port.
     */
    public void setProxy(String host, int port) {
        client.getParams().setParameter(ConnRoutePNames.DEFAULT_PROXY,
                                        new HttpHost(host, port)); // XXX does this really work?
    }

    /**
     * Clear the proxy setting.
     */
    public void clearProxy() {
        client.getParams().removeParameter(ConnRoutePNames.DEFAULT_PROXY); // XXX does this really work?
    }

    /**
     * Clear any user credentials that have been set for this client.
     */
    public void clearCredentials() {
        client.getCredentialsProvider().clear();
        doAuthentication = false;
    }

    public void setUserAgent(String userAgent) {
        this.userAgent = userAgent;
    }

    /**
     * Set the connection timeout for the socket.
     *
     * @param milliseconds The time, expressed as a number of milliseconds.
     */
    public void setSocketTimeout(int milliseconds) {
        client.getParams().setParameter("http.socket.timeout",
                                        Integer.valueOf(milliseconds));
    }

    /**
     * Retrieve the service document. The service document is located at the
     * specified URL. This calls getServiceDocument(url,onBehalfOf).
     *
     * @param url The location of the service document.
     * @return The ServiceDocument, or <code>null</code> if there was a
     * problem accessing the document. e.g. invalid access.
     * @throws SWORDClientException If there is an error accessing the resource.
     */
    public ServiceDocument getServiceDocument(String url)
        throws SWORDClientException {
        return getServiceDocument(url, null);
    }

    /**
     * Retrieve the service document. The service document is located at the
     * specified URL. This calls getServiceDocument(url,onBehalfOf).
     *
     * @param url The location of the service document.
     * @return The ServiceDocument, or <code>null</code> if there was a
     * problem accessing the document. e.g. invalid access.
     * @throws SWORDClientException If there is an error accessing the resource.
     */
    public ServiceDocument getServiceDocument(String url, String onBehalfOf)
        throws SWORDClientException {
        URL serviceDocURL = null;
        try {
            serviceDocURL = new URL(url);
        } catch (MalformedURLException e) {
            // Try relative URL
            URL baseURL = null;
            try {
                baseURL = new URL("http", server, Integer.valueOf(port), "/");
                serviceDocURL = new URL(baseURL, (url == null) ? "" : url);
            } catch (MalformedURLException e1) {
                // No dice, can't even form base URL...
                throw new SWORDClientException(url + " is not a valid URL ("
                                                   + e1.getMessage()
                                                   + "), and could not form a relative one from: "
                                                   + baseURL + " / " + url, e1);
            }
        }

        HttpGet httpget = new HttpGet(serviceDocURL.toExternalForm());
        if (doAuthentication) {
            // this does not perform any check on the username password. It
            // relies on the server to determine if the values are correct.
            setBasicCredentials(username, password);
        }

        Properties properties = new Properties();

        if (containsValue(onBehalfOf)) {
            log.debug("Setting on-behalf-of: " + onBehalfOf);
            httpget.addHeader(url, url);
            httpget.addHeader(HttpHeaders.X_ON_BEHALF_OF, onBehalfOf);
            properties.put(HttpHeaders.X_ON_BEHALF_OF, onBehalfOf);
        }

        if (containsValue(userAgent)) {
            log.debug("Setting userAgent: " + userAgent);
            httpget.addHeader(HttpHeaders.USER_AGENT, userAgent);
            properties.put(HttpHeaders.USER_AGENT, userAgent);
        }

        ServiceDocument doc = null;

        try {
            HttpResponse response = client.execute(httpget);
            StatusLine statusLine = response.getStatusLine();
            int statusCode = statusLine.getStatusCode();
            // store the status code
            status = new Status(statusCode, statusLine.getReasonPhrase());

            if (status.getCode() == HttpStatus.SC_OK) {
                String message = readResponse(response.getEntity().getContent());
                log.debug("returned message is: " + message);
                doc = new ServiceDocument();
                lastUnmarshallInfo = doc.unmarshall(message, properties);
            } else {
                throw new SWORDClientException(
                    "Received error from service document request: "
                        + status);
            }
        } catch (IOException ioex) {
            throw new SWORDClientException(ioex.getMessage(), ioex);
        } catch (UnmarshallException uex) {
            throw new SWORDClientException(uex.getMessage(), uex);
        } finally {
            httpget.releaseConnection();
        }

        return doc;
    }

    private SwordValidationInfo lastUnmarshallInfo;

    /**
     * @return SWORD validation info
     */
    public SwordValidationInfo getLastUnmarshallInfo() {
        return lastUnmarshallInfo;
    }

    /**
     * Post a file to the server. The different elements of the post are encoded
     * in the specified message.
     *
     * @param message The message that contains the post information.
     * @throws SWORDClientException if there is an error during the post operation.
     */
    public DepositResponse postFile(PostMessage message)
        throws SWORDClientException {
        if (message == null) {
            throw new SWORDClientException("Message cannot be null.");
        }

        HttpPost httppost = new HttpPost(message.getDestination());

        if (doAuthentication) {
            setBasicCredentials(username, password);
        }

        DepositResponse response = null;

        String messageBody = "";

        try {
            if (message.isUseMD5()) {
                String md5 = ChecksumUtils.generateMD5(message.getFilepath());
                if (message.getChecksumError()) {
                    md5 = "1234567890";
                }
                log.debug("checksum error is: " + md5);
                if (md5 != null) {
                    httppost.addHeader(HttpHeaders.CONTENT_MD5, md5);
                }
            }

            String filename = message.getFilename();
            if (!"".equals(filename)) {
                httppost.addHeader(HttpHeaders.CONTENT_DISPOSITION,
                                   " filename=" + filename);
            }

            if (containsValue(message.getSlug())) {
                httppost.addHeader(HttpHeaders.SLUG, message.getSlug());
            }

            if (message.getCorruptRequest()) {
                // insert a header with an invalid boolean value
                httppost.addHeader(HttpHeaders.X_NO_OP, "Wibble");
            } else {
                httppost.addHeader(HttpHeaders.X_NO_OP, Boolean
                    .toString(message.isNoOp()));
            }
            httppost.addHeader(HttpHeaders.X_VERBOSE, Boolean
                .toString(message.isVerbose()));

            String packaging = message.getPackaging();
            if (packaging != null && packaging.length() > 0) {
                httppost.addHeader(HttpHeaders.X_PACKAGING, packaging);
            }

            String onBehalfOf = message.getOnBehalfOf();
            if (containsValue(onBehalfOf)) {
                httppost.addHeader(HttpHeaders.X_ON_BEHALF_OF, onBehalfOf);
            }

            String userAgent = message.getUserAgent();
            if (containsValue(userAgent)) {
                httppost.addHeader(HttpHeaders.USER_AGENT, userAgent);
            }


            FileEntity requestEntity = new FileEntity(
                new File(message.getFilepath()),
                ContentType.create(message.getFiletype()));
            httppost.setEntity(requestEntity);

            HttpResponse httpResponse = client.execute(httppost);
            StatusLine statusLine = httpResponse.getStatusLine();
            int statusCode = statusLine.getStatusCode();
            status = new Status(statusCode, statusLine.getReasonPhrase());

            log.info("Checking the status code: " + status.getCode());

            if (status.getCode() == HttpStatus.SC_ACCEPTED
                || status.getCode() == HttpStatus.SC_CREATED) {
                messageBody = readResponse(httpResponse.getEntity().getContent());
                response = new DepositResponse(status.getCode());
                response.setLocation(httpResponse.getFirstHeader("Location").getValue());
                // added call for the status code.
                lastUnmarshallInfo = response.unmarshall(messageBody, new Properties());
            } else {
                messageBody = readResponse(httpResponse.getEntity().getContent());
                response = new DepositResponse(status.getCode());
                response.unmarshallErrorDocument(messageBody);
            }
            return response;

        } catch (NoSuchAlgorithmException nex) {
            throw new SWORDClientException("Unable to use MD5. "
                                               + nex.getMessage(), nex);
        } catch (IOException ioex) {
            throw new SWORDClientException(ioex.getMessage(), ioex);
        } catch (UnmarshallException uex) {
            throw new SWORDClientException(uex.getMessage() + "(<pre>" + messageBody + "</pre>)", uex);
        } finally {
            httppost.releaseConnection();
        }
    }

    /**
     * Read a response from the stream and return it as a string.
     *
     * @param stream The stream that contains the response.
     * @return The string extracted from the screen.
     * @throws UnsupportedEncodingException
     * @throws IOException                  A general class of exceptions produced by failed or interrupted I/O
     *                                      operations.
     */
    private String readResponse(InputStream stream)
        throws UnsupportedEncodingException, IOException {
        BufferedReader reader = new BufferedReader(new InputStreamReader(
            stream, "UTF-8"));
        String line = null;
        StringBuffer buffer = new StringBuffer();
        while ((line = reader.readLine()) != null) {
            buffer.append(line);
            buffer.append("\n");
        }
        return buffer.toString();
    }

    /**
     * Return the status information that was returned from the most recent
     * request sent to the server.
     *
     * @return The status code returned from the most recent access.
     */
    public Status getStatus() {
        return status;
    }

    /**
     * Check to see if the specified item contains a non-empty string.
     *
     * @param item The string to check.
     * @return True if the string is not null and has a length greater than 0
     * after any whitespace is trimmed from the start and end.
     * Otherwise, false.
     */
    private boolean containsValue(String item) {
        return ((item != null) && (item.trim().length() > 0));
    }
}
