/*
 * @(#)Http.java
 *
 * Copyright (c) 2001-2002 JScape
 * 1147 S. 53rd Pl., Mesa, Arizona, 85206, U.S.A.
 * All rights reserved.
 *
 * This software is the confidential and proprietary information of
 * JScape. ("Confidential Information").  You shall not disclose such
 * Confidential Information and shall use it only in accordance with
 * the terms of the license agreement you entered into with JScape.
 */

package com.jscape.inet.http;

import com.jscape.inet.mime.MimeMessage;
import com.jscape.inet.util.TimedSocket;
import com.jscape.inet.util.URLParser;
import com.jscape.util.IOTools;
import com.jscape.util.LineReader;
import com.jscape.util.io.NullOutputStream;

import java.io.*;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.Set;
import java.util.StringTokenizer;
import java.util.concurrent.CopyOnWriteArraySet;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

import static com.jscape.util.Parameters.*;


/**
 * Implements the basic functionality of a HTTP client.<P>
 * Example Usage:
 * <p/>
 * <P>
 * <pre>
 * Http http = new Http();
 *
 * // build HTTP request
 * HttpRequest request = new HttpRequest("http://www.myserver.com");
 *
 * // get HTTP response
 * try
 * {
 *   HttpResponse response = http.getResponse(request);
 *   System.out.println(new String(response.getBody()));
 * }
 * catch(Exception e)
 * {
 *   System.out.println(e);
 * }
 * </pre>
 * </p>
 */
public class Http
        implements Serializable {

    protected static final byte[] EOL = { '\r', '\n' };

    protected static final long DEFAULT_MAX_DATA_SIZE = Long.MAX_VALUE;

    protected static final int DEFAULT_PROXY_PORT = 1080;
    protected static final String DEFAULT_PROXY_TYPE = "SOCKS5";
    protected static final int DEFAULT_BLOCK_SIZE = 1024;

    /**
     * The amount of time in milliseconds to wait before throwing an exception if a HTTP connection cannot be established
     */
    protected int timeout;

    /**
     * The set of registered HttpListener
     */
    protected transient Set<HttpListener> httpListeners;

    /**
     * The HttpRequest to perform
     */
    protected HttpRequest request;

    /**
     * The java.net.Socket used in connecting with HTTP server
     */
    protected transient Socket socket;

    /**
     * The java.io.InputStream used in communicating with HTTP server
     */
    protected transient InputStream inputStream;

    /**
     * The java.io.OutputStream used in communicating with HTTP server
     */
    protected transient OutputStream outputStream;

    /**
     * Represents whether debugging is enabled
     */
    protected boolean debug;

    /**
     * Represents the java.io.PrintStream used for debugging statements.  Default value is System.out
     */
    protected transient PrintStream debugStream;

    /**
     * Specifies the number of bytes written in each block of data written to server
     */
    protected int writeBlockSize;

    /**
     * Specified the number of bytes read in each block of data read from server
     */
    protected int readBlockSize;

    protected long maxDataSize;

    protected String proxyHostname;
    protected int proxyPort;
    protected String proxyUsername;
    protected String proxyPassword;
    protected String proxyType;
    protected final Lock lock;

    /**
     * Constructs a new <code>Http</code> instance.
     */
    public Http() {
        

        this.httpListeners = new CopyOnWriteArraySet<>();
        this.debugStream = new PrintStream(new NullOutputStream());
        this.writeBlockSize = DEFAULT_BLOCK_SIZE;
        this.readBlockSize = DEFAULT_BLOCK_SIZE;
        this.maxDataSize = DEFAULT_MAX_DATA_SIZE;
        this.proxyPort = DEFAULT_PROXY_PORT;
        this.proxyType = DEFAULT_PROXY_TYPE;

        this.lock = new ReentrantLock();
    }

    /**
     * Gets the timeout used when opening a HTTP connection. Default of 0ms
     * uses natural timeout of socket.
     *
     * @return timeout in milliseconds
     */
    public int getTimeout() {
        return this.timeout;
    }

    /**
     * Sets the timeout for opening a HTTP connection. Default of 0ms uses
     * natural timeout of socket.
     *
     * @param value in milliseconds
     */
    public void setTimeout(int value) {
        assertGreaterOrEqual(value, 0, "Bad timeout value.");
        this.timeout = value;
    }

    /**
     * Gets debug level of HTTP session. Debug messages are reported to debug
     * stream.  Default debug stream is System.out
     *
     * @return <code>true</code> if debugging is on
     *
     * @see #setDebugStream
     */
    public boolean isDebug() {
        return this.debug;
    }

    /**
     * Sets debugging state. All debug statements are reported to debug stream.
     * Default debug stream is System.out
     *
     * @param value <code>true</code> to turn debugging on
     *
     * @see #setDebugStream
     */
    public void setDebug(boolean value) {
        this.debug = value;
    }

    /**
     * Gets PrintStream used in reporting debug statements. Default PrintStream
     * is System.out
     *
     * @return the PrintStream used for debug statements
     *
     * @see java.io.PrintStream
     */
    public PrintStream getDebugStream() {
        return this.debugStream;
    }

    /**
     * Sets PrintStream used in reporting debug statements. Default PrintStream
     * is System.out
     *
     * @param debugStream the PrintStream to send debug statements to
     *
     * @see java.io.PrintStream
     */
    public void setDebugStream(PrintStream debugStream) {
        assertNotNull(debugStream);
        this.debugStream = debugStream;
    }

    /**
     * Gets number of bytes written in each block to server
     *
     * @return block size
     */
    public int getWriteBlockSize() {
        return this.writeBlockSize;
    }

    /**
     * Sets number of bytes written in each block to server
     *
     * @param value block size
     */
    public void setWriteBlockSize(int value) {
        assertGreater(value, 0, "Bad write block size value.");
        this.writeBlockSize = value;
    }

    /**
     * Gets the number of bytes read in each block of data read from server
     *
     * @return block size
     */
    public int getReadBlockSize() {
        return this.readBlockSize;
    }

    /**
     * Sets the number of bytes read in each block of data read from server
     *
     * @param value block size
     */
    public void setReadBlockSize(int value) {
        assertGreater(value, 0, "Bad read block size  value.");
        this.readBlockSize = value;
    }

    /**
     * Returns max data size.
     *
     * @return max data size in bytes
     */
    public long getMaxDataSize() {
        return this.maxDataSize;
    }

    /**
     * Sets max data size.
     *
     * @param value the max data size in bytes
     */
    public void setMaxDataSize(long value) {
        this.maxDataSize = value;
    }

    /**
     * Sets the username and password to use when for authentication with
     * proxy server.
     * To clear these settings invoke the <code>#clearProxySettings</code> method.
     *
     * @param proxyUsername the proxy username
     * @param proxyPassword the proxy password
     *
     * @see #clearProxySettings
     */
    public void setProxyAuthentication(String proxyUsername, String proxyPassword) {
        this.proxyUsername = proxyUsername;
        this.proxyPassword = proxyPassword;
    }

    /**
     * Sets the proxy hostname and port for this connection.
     * To clear these settings invoke the <code>#clearProxySettings</code> method.
     *
     * @param proxyHostname the hostname or ip address of the proxy server
     * @param proxyPort     the port of the proxy server
     *
     * @see #clearProxySettings
     */
    public void setProxyHost(String proxyHostname, int proxyPort) {
        this.proxyHostname = proxyHostname;
        this.proxyPort = proxyPort;
    }

    /**
     * Sets the proxy type will be used for this connection.
     *
     * @param proxyType The proxy type. Valid values: HTTP, SOCKS5
     */
    public void setProxyType(String proxyType) {
        this.proxyType = proxyType;
    }

    /**
     * Clears proxy server values.
     */
    public void clearProxySettings() {
        this.proxyUsername = null;
        this.proxyPassword = null;
        this.proxyHostname = null;
        this.proxyPort = DEFAULT_PROXY_PORT;
        this.proxyType = DEFAULT_PROXY_TYPE;
    }

    /**
     * Adds an event listener for events implemented by
     * <code>HttpListener</code> interface.
     *
     * @param listener an <code>HttpListener</code>
     *
     * @see HttpListener
     */
    public void addHttpListener(HttpListener listener) {
        assertNotNull(listener);
        this.httpListeners.add(listener);
    }

    /**
     * Removes instance of <code>HttpListener</code>.
     *
     * @param listener an <code>HttpListener</code>
     *
     * @see HttpListener
     */
    public void removeHttpListener(HttpListener listener) {
        this.httpListeners.remove(listener);
    }

    /**
     * Gets response to HTTP request.
     *
     * @param request the HttpFileUpload to perform
     *
     * @return an <code>HttpResponse</code>
     *
     * @throws HttpException thrown if an I/O or HTTP related error occurs
     * @see HttpResponse
     * @see HttpException
     */
    public HttpResponse getResponse(HttpFileUpload request)
            throws HttpException {
        assertNotNull(request);

        this.lock.lock();

        try {
            this.request = request;
            return doPost(request);
        } finally {
            this.lock.unlock();
        }
    }

    /**
     * Gets response to HTTP request.
     *
     * @param request the HttpRequest to perform
     *
     * @return an <code>HttpResponse</code>
     *
     * @throws HttpException thrown if an I/O or HTTP related error occurs
     * @see HttpResponse
     * @see HttpException
     */
    public HttpResponse getResponse(HttpRequest request)
            throws HttpException {
        assertNotNull(request);

        this.lock.lock();

        try {
            this.request = request;

            switch (request.getMethod()) {
                case "POST":
                case "PUT":
                    return doPost();
                case "HEAD":
                    return doHead();
                default:
                    return doGet();
            }
        } finally {
            this.lock.unlock();
        }
    }

    /**
     * Gets response to HTTP request and writes contents to file.
     *
     * @param request the HttpRequest to perform
     * @param file    the file to write the response to
     *
     * @return the <code>HttpResponse</code> written to file
     *
     * @throws HttpException thrown if an I/O or HTTP related error occurs
     * @see HttpResponse
     * @see HttpException
     */
    public HttpResponse getResponseToFile(HttpRequest request, File file)
            throws HttpException {
        OutputStream out = null;

        this.lock.lock();

        try {
            HttpResponse response = getResponse(request);

            out = new FileOutputStream(file);
            out.write(response.getBodyData());

            return response;
        } catch (Exception e) {
            throw HttpException.wrap(e);
        } finally {
            this.lock.unlock();
            IOTools.close(out);
        }
    }

    /**
     * Connects to HTTP server. The socket is created using TimedSocket and
     * respects timeout.
     *
     * @throws IOException thrown if an I/O related error occurs
     * @see com.jscape.inet.util.TimedSocket
     */
    protected void connect()
            throws IOException {
        URLParser url = new URLParser(request.getURL());

        String serverHost = url.getHost();
        int serverPort = url.getPort();

        if (serverPort == -1) {
            serverPort = 80;
        }

        // connect etc.
        if (request.getProxyHost() == null) {
            if (this.proxyHostname == null) {
                socket = TimedSocket.getSocket(serverHost, serverPort, timeout);
            } else {
                socket = TimedSocket.getSocket(serverHost, serverPort, timeout,
                                               this.proxyHostname, this.proxyPort, this.proxyUsername,
                                               this.proxyPassword, this.proxyType);
            }
            if (timeout > 0) {
                socket.setSoTimeout(timeout);
            }
            outputStream = socket.getOutputStream();
            inputStream = socket.getInputStream();
        } else {
            if (this.proxyHostname == null) {
                socket = TimedSocket.getSocket(request.getProxyHost(), request.getProxyPort(), timeout);
            } else {
                socket = TimedSocket.getSocket(request.getProxyHost(), request.getProxyPort(), timeout,
                                               this.proxyHostname, this.proxyPort, this.proxyUsername,
                                               this.proxyPassword, this.proxyType);

            }
            if (timeout > 0) {
                socket.setSoTimeout(timeout);
            }
            outputStream = socket.getOutputStream();
            inputStream = socket.getInputStream();
        }

        fireHttpConnected();
    }

    /**
     * Closes connection to HTTP server
     */
    protected void disconnect() {
        IOTools.close(this.socket);
        fireHttpDisconnected();
    }

    /**
     * Handles POST request for a file upload.
     *
     * @param request the <CODE>HttpRequest</CODE>
     *
     * @return a <CODE>HttpResponse</CODE>
     *
     * @throws HttpException if an I/O error occurs
     */
    protected HttpResponse doPost(HttpFileUpload request)
            throws HttpException {

        if (!request.isStreamingEnabled()) {
            return doPost();
        }

        HttpResponse response = null;

        try {
            connect();

            String requestLine = request.getRequestLine(false);
            outputStream.write(requestLine.getBytes(StandardCharsets.US_ASCII));
            outputStream.write("\r\n".getBytes(StandardCharsets.US_ASCII));

            debug("---REQUEST---");
            debug(requestLine);

            File bfile = null;
            Enumeration parts = request.getParts();
            while (parts.hasMoreElements()) {
                MimeMessage fileMessage = (MimeMessage) parts.nextElement();

                if (fileMessage.getBodyAsFile() != null) {
                    bfile = fileMessage.getBodyAsFile();
                    break;
                }
            }

            if (bfile == null) {
                throw new HttpException("Unable to locate file data.");
            }

            byte[] msg = request.getMessage();
            String entireMessage = new String(msg, StandardCharsets.UTF_8);
            int index = entireMessage.indexOf(HttpFileUpload.PLACEHOLDER);

            String firstPart = entireMessage.substring(0, index);
            String lastPart = entireMessage.substring(firstPart.length() + HttpFileUpload.PLACEHOLDER.length());
            msg = firstPart.getBytes(StandardCharsets.US_ASCII);

            // write the first part of the message to the output stream
            outputStream.write(msg, 0, msg.length);

            fireHttpProgress(msg.length, HttpProgressEvent.MODE_UPLOAD);

            // upload the file data
            int bytesWritten = msg.length;
            FileInputStream fis = new FileInputStream(bfile);
            byte[] buffer = new byte[writeBlockSize];
            int read;
            while ((read = fis.read(buffer, 0, buffer.length)) != -1) {
                outputStream.write(buffer, 0, read);
                bytesWritten += read;
                fireHttpProgress(bytesWritten, HttpProgressEvent.MODE_UPLOAD);
            }
            fis.close();

            outputStream.flush();

            // write the boundary to the output stream after the file data has been uploaded.
            msg = lastPart.getBytes(StandardCharsets.US_ASCII);
            outputStream.write(msg, 0, msg.length);
            outputStream.flush();

            debug(entireMessage);
            debug("---END REQUEST---");

            fireHttpProgress(msg.length, HttpProgressEvent.MODE_UPLOAD);

            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            LineReader lineReader = new LineReader(inputStream);
            String httpResponse = lineReader.readLine();

            debug("---RESPONSE---");
            debug(httpResponse);

            // download data and fire progress event
            buffer = new byte[(int) Math.min(this.readBlockSize, this.maxDataSize)];
            long bytesRead = 0;
            int b;
            while (bytesRead < this.maxDataSize && (b = inputStream.read(buffer)) != -1) {
                bout.write(buffer, 0, b);
                bytesRead += b;
                fireHttpProgress(bytesRead, HttpProgressEvent.MODE_DOWNLOAD);
            }

            // build response
            byte[] data = bout.toByteArray();

            debug("---DATA---");
            debug(new String(data, StandardCharsets.UTF_8));
            debug("---END DATA---");
            debug("---END RESPONSE---");

            return new HttpResponse(data);
        } catch (Exception e) {
            e.printStackTrace();
            throw new HttpException(e.getMessage(), e);
        } finally {
            disconnect();
        }
    }

    /**
     * Handles POST request
     *
     * @return a <CODE>HttpResponse</CODE>
     *
     * @throws HttpException if an I/O error occurs
     */
    protected HttpResponse doPost()
            throws HttpException {
        HttpResponse response = null;

        try {
            connect();

            String requestLine = request.getRequestLine(false);
            outputStream.write(requestLine.getBytes(StandardCharsets.US_ASCII));
            outputStream.write("\r\n".getBytes(StandardCharsets.US_ASCII));

            debug("---REQUEST---");
            debug(requestLine);

            // get message            
            byte[] msg = request.getMessage();

            // variable to track lengh of message buffer
            int length = msg.length;

            // variable to track bytes written            
            int bytesWritten = 0;

            while (bytesWritten < length) {
                // if remainder available is >= writeBlockSize then write block
                if (length - bytesWritten >= writeBlockSize) {
                    outputStream.write(msg, bytesWritten, writeBlockSize);
                    bytesWritten += writeBlockSize;

                    // fire event
                    fireHttpProgress(bytesWritten, HttpProgressEvent.MODE_UPLOAD);
                } else {
                    outputStream.write(msg, bytesWritten, length - bytesWritten);
                    bytesWritten += (length - bytesWritten);

                    // fire event
                    fireHttpProgress(bytesWritten, HttpProgressEvent.MODE_UPLOAD);
                }
            }

            //outputStream.write(request.getMessage());
            outputStream.flush();

            debug(new String(msg));
            debug("---END REQUEST---");

            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            LineReader lineReader = new LineReader(inputStream);
            String httpResponse = lineReader.readLine();

            debug("---RESPONSE---");
            debug(httpResponse);

            // download data and fire progress event
            byte[] buffer = new byte[(int) Math.min(this.readBlockSize, this.maxDataSize)];
            long bytesRead = 0;
            int b;
            while (bytesRead < this.maxDataSize && (b = inputStream.read(buffer)) != -1) {
                bout.write(buffer, 0, b);
                bytesRead += b;
                fireHttpProgress(bytesRead, HttpProgressEvent.MODE_DOWNLOAD);
            }

            // build response
            byte[] data = bout.toByteArray();

            debug("---DATA---");
            debug(new String(data, StandardCharsets.UTF_8));

            response = new HttpResponse(data);
            debug("---PARSE RESPONSE CODE---");
            response.setResponseCode(getHttpResponseCode(httpResponse));
            debug("---PARSE VERSION---");
            response.setHttpVersion(getHttpVersion(httpResponse));
            debug("---END DATA---");
            debug("---END RESPONSE---");

        } catch (Exception e) {
            throw new HttpException(e.getMessage(), e);
        } finally {
            disconnect();
        }

        return response;
    }

    /**
     * Handles HEAD request.
     *
     * @return a <CODE>HttpResponse</CODE>
     *
     * @throws HttpException if an I/O error occurs
     */
    protected HttpResponse doHead()
            throws HttpException {
        HttpResponse response = null;

        try {
            connect();

            String requestLine = request.getRequestLine(true);
            outputStream.write(requestLine.getBytes(StandardCharsets.US_ASCII));
            outputStream.write("\r\n".getBytes(StandardCharsets.US_ASCII));

            debug("---REQUEST---");
            debug(requestLine);

            outputStream.write(request.getMessage());
            outputStream.flush();

            debug(new String(request.getMessage()));
            debug("---END REQUEST---");

            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            LineReader lineReader = new LineReader(inputStream);
            String httpResponse = lineReader.readLine();

            debug("---RESPONSE---");
            debug(httpResponse);

            // download data and fire progress event
            byte[] buffer = new byte[(int) Math.min(this.readBlockSize, this.maxDataSize)];
            long bytesRead = 0;
            int b;
            while (bytesRead < this.maxDataSize && (b = inputStream.read(buffer)) != -1) {
                bout.write(buffer, 0, b);
                bytesRead += b;
                fireHttpProgress(bytesRead, HttpProgressEvent.MODE_DOWNLOAD);
            }

            debug(new String(bout.toByteArray(), StandardCharsets.UTF_8));
            debug("---END RESPONSE---");

            // build response
            response = new HttpResponse(bout.toByteArray());
            response.setResponseCode(getHttpResponseCode(httpResponse));
            response.setHttpVersion(getHttpVersion(httpResponse));
        } catch (Exception e) {
            throw new HttpException(e.getMessage(), e);
        } finally {
            disconnect();
        }
        return response;
    }

    /**
     * Handles GET request
     *
     * @return a <CODE>HttpResponse</CODE>
     *
     * @throws HttpException if an I/O error occurs
     */
    protected HttpResponse doGet()
            throws HttpException {
        HttpResponse response = null;

        try {
            connect();
            String requestLine = request.getRequestLine(true);
            outputStream.write(requestLine.getBytes(StandardCharsets.US_ASCII));
            outputStream.write("\r\n".getBytes(StandardCharsets.US_ASCII));

            debug("---REQUEST---");
            debug(requestLine);

            outputStream.write(request.getMessage());
            outputStream.flush();

            debug(new String(request.getMessage()));
            debug("---END REQUEST---");

            ByteArrayOutputStream bout = new ByteArrayOutputStream();
            LineReader lineReader = new LineReader(inputStream);
            String httpResponse = lineReader.readLine();

            debug("---RESPONSE---");
            debug(httpResponse);

            // download data and fire progress event
            byte[] buffer = new byte[(int) Math.min(this.readBlockSize, this.maxDataSize)];
            long bytesRead = 0;
            int b;
            while (bytesRead < this.maxDataSize && (b = inputStream.read(buffer)) != -1) {
                bout.write(buffer, 0, b);
                bytesRead += b;
                fireHttpProgress(bytesRead, HttpProgressEvent.MODE_DOWNLOAD);
            }

            debug(new String(bout.toByteArray(), StandardCharsets.UTF_8));
            debug("---END RESPONSE---");

            // build response
            response = new HttpResponse(bout.toByteArray());
            response.setResponseCode(getHttpResponseCode(httpResponse));
            response.setHttpVersion(getHttpVersion(httpResponse));
        } catch (Exception e) {
            throw new HttpException(e.getMessage(), e);
        } finally {
            disconnect();
        }
        return response;
    }

    /**
     * Parses out and set version and response code from response.
     *
     * @param str response string issued by Http server
     *
     * @return the HTTP response code
     */
    protected int getHttpResponseCode(String str) {
        int httpResponseCode = -1;

        if (str != null) {
            StringTokenizer tokenizer = new StringTokenizer(str, " ");
            tokenizer.nextToken();
            httpResponseCode = Integer.parseInt(tokenizer.nextToken());
        }

        return httpResponseCode;
    }

    /**
     * Parses out and set version and response code from response.
     *
     * @param str response string issued by Http server
     *
     * @return the HTTP version returned in response
     */
    protected String getHttpVersion(String str) {
        if (str == null) {
            return null;
        }

        try {
            StringTokenizer tokenizer = new StringTokenizer(str, " ");
            String httpVersion = tokenizer.nextToken();
            tokenizer = new StringTokenizer(httpVersion, "/");
            tokenizer.nextToken();

            return tokenizer.nextToken();
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * Prints information to debug stream. Default debug stream is System.out
     *
     * @param msg the message to print
     *
     * @see #setDebug
     */
    protected void debug(String msg) {
        if (this.debug) {
            this.debugStream.println(msg);
        }
    }

    /**
     * Fires <code>HttpConnectedEvent</code>. <code>HttpConnectedEvent</code>
     * is fired after <code>getResponse</code> method is invoked and HTTP
     * connection has been established.
     *
     * @see HttpConnectedEvent
     * @see Http#getResponse
     */
    protected void fireHttpConnected() {
        HttpConnectedEvent event = new HttpConnectedEvent(this, this.request.getURL());

        for (HttpListener listener : this.httpListeners) {
            listener.connected(event);
        }
    }

    /**
     * Fires <code>HttpProgressEvent</code>. <code>HttpProgressEvent</code> is fired
     * to mark the current download progress.
     *
     * @param bytes the total number of bytes transferred thus far
     * @param mode  the transfer mode of upload or download
     *
     * @see HttpProgressEvent
     */
    protected void fireHttpProgress(long bytes, int mode) {
        HttpProgressEvent event = new HttpProgressEvent(this, this.request.getURL(), bytes, mode);

        for (HttpListener listener : this.httpListeners) {
            listener.progress(event);
        }
    }

    /**
     * Fires <code>HttpDisconnectedEvent</code>.
     * <code>HttpDisconnectedEvent</code> is fired after HTTP response is
     * received and connection is closed to HTTP server.
     *
     * @see HttpDisconnectedEvent
     */
    protected void fireHttpDisconnected() {
        HttpDisconnectedEvent event = new HttpDisconnectedEvent(this, this.request.getURL());

        for (HttpListener listener : this.httpListeners) {
            listener.disconnected(event);
        }
    }

}
