/*
 * @(#)HttpURLConnection.java				0.3-3 06/05/2001
 *
 *  This file is part of the HTTPClient package
 *  Copyright (C) 1996-2001 Ronald Tschalär
 *
 *  This library is free software; you can redistribute it and/or
 *  modify it under the terms of the GNU Lesser General Public
 *  License as published by the Free Software Foundation; either
 *  version 2 of the License, or (at your option) any later version.
 *
 *  This library 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
 *  Lesser General Public License for more details.
 *
 *  You should have received a copy of the GNU Lesser General Public
 *  License along with this library; if not, write to the Free
 *  Software Foundation, Inc., 59 Temple Place, Suite 330, Boston,
 *  MA 02111-1307, USA
 *
 *  For questions, suggestions, bug-reports, enhancement-requests etc.
 *  I may be contacted at:
 *
 *  ronald@innovation.ch
 *
 *  The HTTPClient's home page is located at:
 *
 *  http://www.innovation.ch/java/HTTPClient/ 
 *
 */

package HTTPClient;

import java.net.URL;
import java.net.ProtocolException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.ByteArrayOutputStream;
import java.util.Date;
import java.util.Hashtable;
import java.util.Enumeration;


/**
 * This class is a wrapper around HTTPConnection providing the interface
 * defined by java.net.URLConnection and java.net.HttpURLConnection.
 *
 * <P>This class can be used to replace the HttpClient in the JDK with this
 * HTTPClient by defining the property
 * <code>java.protocol.handler.pkgs=HTTPClient</code>.
 *
 * <P>One difference between Sun's HttpClient and this one is that this
 * one will provide you with a real output stream if possible. This leads
 * to two changes: you should set the request property "Content-Length",
 * if possible, before invoking getOutputStream(); and in many cases
 * getOutputStream() implies connect(). This should be transparent, though,
 * apart from the fact that you can't change any headers or other settings
 * anymore once you've gotten the output stream.
 * So, for large data do:
 * <PRE>
 *   HttpURLConnection con = (HttpURLConnection) url.openConnection();
 *
 *   con.setDoOutput(true);
 *   con.setRequestProperty("Content-Length", ...);
 *   OutputStream out = con.getOutputStream();
 *
 *   out.write(...);
 *   out.close();
 *
 *   if (con.getResponseCode() != 200)
 *       ...
 * </PRE>
 *
 * <P>The HTTPClient will send the request data using the chunked transfer
 * encoding when no Content-Length is specified and the server is HTTP/1.1
 * compatible. Because cgi-scripts can't usually handle this, you may
 * experience problems trying to POST data. For this reason, whenever
 * the Content-Type is application/x-www-form-urlencoded getOutputStream()
 * will buffer the data before sending it so as prevent chunking. If you
 * are sending requests with a different Content-Type and are experiencing
 * problems then you may want to try setting the system property
 * <var>HTTPClient.dontChunkRequests</var> to <var>true</var> (this needs
 * to be done either on the command line or somewhere in the code before
 * the first URLConnection.openConnection() is invoked).
 *
 * <P>A second potential incompatibility is that the HTTPClient aggresively
 * resuses connections, and can do so more often that Sun's client. This
 * can cause problems if you send multiple requests, and the first one has
 * a long response. In this case (assuming the server allows the connection
 * to be used for multiple requests) the responses to second, third, etc
 * request won't be received until the first response has been completely
 * read. With Sun's client on the other hand you may not experience this,
 * as it may not be able to keep the connection open and there may create
 * multiple connections for the requests. This allows the responses to the
 * second, third, etc requests to be read before the first response has
 * completed. <strong>Note:</strong> whether this will happen depends on
 * details of the resource being requested and the server. In many cases
 * the HTTPClient and Sun's client will exhibit the same behaviour. Also,
 * applications which depend on being able to read the second response
 * before the first one has completed must be considered broken, because
 * A) this behaviour cannot be relied upon even in Sun's current client,
 * and B) Sun's implementation will exhibit the same problem if they ever
 * switch to HTTP/1.1.
 *
 * @version	0.3-3  06/05/2001
 * @author	Ronald Tschalär
 * @since	V0.3
 */
public class HttpURLConnection extends java.net.HttpURLConnection
{
    /** the cache of HTTPConnections */
    protected static Hashtable  connections = new Hashtable();

    /** the current connection */
    protected HTTPConnection    con;

    /** the cached url.toString() */
    private String            urlString;

    /** the resource */
    private String            resource;

    /** the current method */
    private String            method;

    /** has the current method been set via setRequestMethod()? */
    private boolean           method_set;

    /** the default request headers */
    private static NVPair[]   default_headers = new NVPair[0];

    /** the request headers */
    private NVPair[]          headers;

    /** the response */
    protected HTTPResponse      resp;

    /** is the redirection module activated for this instance? */
    private boolean           do_redir;

    /** the RedirectionModule class */
    private static Class      redir_mod;

    /** the output stream used for POST and PUT */
    private OutputStream      output_stream;


    static
    {
	// The default allowUserAction in java.net.URLConnection is
	// false.
	try
	{
	    if (Boolean.getBoolean("HTTPClient.HttpURLConnection.AllowUI"))
		setDefaultAllowUserInteraction(true);
	}
	catch (SecurityException se)
	    { }

	// get the RedirectionModule class
	try
	    { redir_mod = Class.forName("HTTPClient.RedirectionModule"); }
	catch (ClassNotFoundException cnfe)
	    { throw new NoClassDefFoundError(cnfe.getMessage()); }

	// Set the User-Agent if the http.agent property is set
	try
	{
	    String agent = System.getProperty("http.agent");
	    if (agent != null)
		setDefaultRequestProperty("User-Agent", agent);
	}
	catch (SecurityException se)
	    { }
    }


    // Constructors

    private static String non_proxy_hosts = "";
    private static String proxy_host = "";
    private static int    proxy_port = -1;

    /**
     * Construct a connection to the specified url. A cache of
     * HTTPConnections is used to maximize the reuse of these across
     * multiple HttpURLConnections.
     *
     * <BR>The default method is "GET".
     *
     * @param url the url of the request
     * @exception ProtocolNotSuppException if the protocol is not supported
     */
    public HttpURLConnection(URL url)
	    throws ProtocolNotSuppException, IOException
    {
	super(url);

	// first read proxy properties and set
        try
        {
            String hosts = System.getProperty("http.nonProxyHosts", "");
	    if (!hosts.equalsIgnoreCase(non_proxy_hosts))
	    {
		connections.clear();
		non_proxy_hosts = hosts;
		String[] list = Util.splitProperty(hosts);
		for (int idx=0; idx<list.length; idx++)
		    HTTPConnection.dontProxyFor(list[idx]);
	    }
        }
        catch (ParseException pe)
	    { throw new IOException(pe.toString()); }
        catch (SecurityException se)
            { }

	try
	{
	    String host = System.getProperty("http.proxyHost", "");
	    int port = Integer.getInteger("http.proxyPort", -1).intValue();
	    if (!host.equalsIgnoreCase(proxy_host)  ||  port != proxy_port)
	    {
		connections.clear();
		proxy_host = host;
		proxy_port = port;
		HTTPConnection.setProxyServer(host, port);
	    }
	}
	catch (SecurityException se)
	    { }

	// now setup stuff
	con           = getConnection(url);
	method        = "GET";
	method_set    = false;
	resource      = url.getFile();
	headers       = default_headers;
	do_redir      = getFollowRedirects();
	output_stream = null;

	urlString     = url.toString();
    }


    /**
     * Returns an HTTPConnection. A cache of connections is kept and first
     * consulted; only when the cache lookup fails is a new one created
     * and added to the cache.
     *
     * @param url the url
     * @return an HTTPConnection
     * @exception ProtocolNotSuppException if the protocol is not supported
     */
    protected HTTPConnection getConnection(URL url)
	    throws ProtocolNotSuppException
    {
	// try the cache, using the host name

	String php = url.getProtocol() + ":" + url.getHost() + ":" +
		     ((url.getPort() != -1) ? url.getPort() :
					URI.defaultPort(url.getProtocol()));
	php = php.toLowerCase();

	HTTPConnection con = (HTTPConnection) connections.get(php);
	if (con != null)  return con;


	// Not in cache, so create new one and cache it

	con = new HTTPConnection(url);
	connections.put(php, con);

	return con;
    }


    // Methods

    /**
     * Sets the request method (e.g. "PUT" or "HEAD"). Can only be set
     * before connect() is called.
     *
     * @param method the http method.
     * @exception ProtocolException if already connected.
     */
    public void setRequestMethod(String method)  throws ProtocolException
    {
	if (connected)
	    throw new ProtocolException("Already connected!");

	Log.write(Log.URLC, "URLC:  (" + urlString + ") Setting request method: " +
			    method);

	this.method = method.trim().toUpperCase();
	method_set  = true;
    }


    /**
     * Return the request method used.
     *
     * @return the http method.
     */
    public String getRequestMethod()
    {
	return method;
    }


    /**
     * Get the response code. Calls connect() if not connected.
     *
     * @return the http response code returned.
     */
    public int getResponseCode()  throws IOException
    {
	if (!connected)  connect();

	try
	    { return resp.getStatusCode(); }
	catch (ModuleException me)
	    { throw new IOException(me.toString()); }
    }


    /**
     * Get the response message describing the response code. Calls connect()
     * if not connected.
     *
     * @return the http response message returned with the response code.
     */
    public String getResponseMessage()  throws IOException
    {
	if (!connected)  connect();

	try
	    { return resp.getReasonLine(); }
	catch (ModuleException me)
	    { throw new IOException(me.toString()); }
    }


    /**
     * Get the value part of a header. Calls connect() if not connected.
     *
     * @param  name the of the header.
     * @return the value of the header, or null if no such header was returned.
     */
    public String getHeaderField(String name)
    {
	try
	{
	    if (!connected)  connect();
	    return resp.getHeader(name);
	}
	catch (Exception e)
	    { return null; }
    }


    /**
     * Get the value part of a header and converts it to an int. If the
     * header does not exist or if its value could not be converted to an
     * int then the default is returned. Calls connect() if not connected.
     *
     * @param  name the of the header.
     * @param  def  the default value to return in case of an error.
     * @return the value of the header, or null if no such header was returned.
     */
    public int getHeaderFieldInt(String name, int def)
    {
	try
	{
	    if (!connected)  connect();
	    return resp.getHeaderAsInt(name);
	}
	catch (Exception e)
	    { return def; }
    }


    /**
     * Get the value part of a header, interprets it as a date and converts
     * it to a long representing the number of milliseconds since 1970. If
     * the header does not exist or if its value could not be converted to a
     * date then the default is returned. Calls connect() if not connected.
     *
     * @param  name the of the header.
     * @param  def  the default value to return in case of an error.
     * @return the value of the header, or def in case of an error.
     */
    public long getHeaderFieldDate(String name, long def)
    {
	try
	{
	    if (!connected)  connect();
	    return resp.getHeaderAsDate(name).getTime();
	}
	catch (Exception e)
	    { return def; }
    }


    private String[] hdr_keys, hdr_values;

    /**
     * Gets header name of the n-th header. Calls connect() if not connected.
     * The name of the 0-th header is <var>null</var>, even though it the
     * 0-th header has a value.
     *
     * @param n which header to return.
     * @return the header name, or null if not that many headers.
     */
    public String getHeaderFieldKey(int n)
    {
	if (hdr_keys == null)
	    fill_hdr_arrays();

	if (n >= 0  &&  n < hdr_keys.length)
	    return hdr_keys[n];
	else
	    return null;
    }


    /**
     * Gets header value of the n-th header. Calls connect() if not connected.
     * The value of 0-th header is the Status-Line (e.g. "HTTP/1.1 200 Ok").
     *
     * @param n which header to return.
     * @return the header value, or null if not that many headers.
     */
    public String getHeaderField(int n)
    {
	if (hdr_values == null)
	    fill_hdr_arrays();

	if (n >= 0  &&  n < hdr_values.length)
	    return hdr_values[n];
	else
	    return null;
    }


    /**
     * Cache the list of headers.
     */
    private void fill_hdr_arrays()
    {
	try
	{
	    if (!connected)  connect();

	    // count number of headers
	    int num = 1;
	    Enumeration enum = resp.listHeaders();
	    while (enum.hasMoreElements())
	    {
		num++;
		enum.nextElement();
	    }

	    // allocate arrays
	    hdr_keys   = new String[num];
	    hdr_values = new String[num];

	    // fill arrays
	    enum = resp.listHeaders();
	    for (int idx=1; idx<num; idx++)
	    {
		hdr_keys[idx]   = (String) enum.nextElement();
		hdr_values[idx] = resp.getHeader(hdr_keys[idx]);
	    }

	    // the 0'th field is special
	    hdr_values[0] = resp.getVersion() + " " + resp.getStatusCode() +
			    " " + resp.getReasonLine();
	}
	catch (Exception e)
	    { hdr_keys = hdr_values = new String[0]; }
    }


    /**
     * Gets an input stream from which the data in the response may be read.
     * Calls connect() if not connected.
     *
     * @return an InputStream
     * @exception ProtocolException if input not enabled.
     * @see java.net.URLConnection#setDoInput(boolean)
     */
    public InputStream getInputStream()  throws IOException
    {
	if (!doInput)
	    throw new ProtocolException("Input not enabled! (use setDoInput(true))");

	if (!connected)  connect();

	InputStream stream;
	try
	    { stream = resp.getInputStream(); }
	catch (ModuleException e)
	    { throw new IOException(e.toString()); }

	return stream;
    }


    /**
     * Returns the error stream if the connection failed
     * but the server sent useful data nonetheless.
     *
     * <P>This method will not cause a connection to be initiated.
     *
     * @return an InputStream, or null if either the connection hasn't
     *         been established yet or no error occured
     * @see java.net.HttpURLConnection#getErrorStream()
     * @since V0.3-1
     */
    public InputStream getErrorStream()
    {
	try
	{
	    if (!doInput  ||  !connected  ||  resp.getStatusCode() < 300  ||
		resp.getHeaderAsInt("Content-length") <= 0)
		return null;

	    return resp.getInputStream();
	}
	catch (Exception e)
	    { return null; }
    }


    /**
     * Gets an output stream which can be used send an entity with the
     * request. Can be called multiple times, in which case always the
     * same stream is returned.
     *
     * <P>The default request method changes to "POST" when this method is
     * called. Cannot be called after connect().
     *
     * <P>If no Content-type has been set it defaults to
     * <var>application/x-www-form-urlencoded</var>. Furthermore, if the
     * Content-type is <var>application/x-www-form-urlencoded</var> then all
     * output will be collected in a buffer before sending it to the server;
     * otherwise an HttpOutputStream is used.
     *
     * @return an OutputStream
     * @exception ProtocolException if already connect()'ed, if output is not
     *                              enabled or if the request method does not
     *                              support output.
     * @see java.net.URLConnection#setDoOutput(boolean)
     * @see HTTPClient.HttpOutputStream
     */
    public synchronized OutputStream getOutputStream()  throws IOException
    {
	if (connected)
	    throw new ProtocolException("Already connected!");

	if (!doOutput)
	    throw new ProtocolException("Output not enabled! (use setDoOutput(true))");
	if (!method_set)
	    method = "POST";
	else if (method.equals("HEAD")  ||  method.equals("GET")  ||
		 method.equals("TRACE"))
	    throw new ProtocolException("Method "+method+" does not support output!");

	if (getRequestProperty("Content-type") == null)
	    setRequestProperty("Content-type", "application/x-www-form-urlencoded");

	if (output_stream == null)
	{
	    Log.write(Log.URLC, "URLC:  (" + urlString + ") creating output stream");

	    String cl = getRequestProperty("Content-Length");
	    if (cl != null)
		output_stream = new HttpOutputStream(Integer.parseInt(cl.trim()));
	    else
	    {
		// Hack: because of restrictions when using true output streams
		// and because form-data is usually quite limited in size, we
		// first collect all data before sending it if this is
		// form-data.
		if (getRequestProperty("Content-type").equals(
			"application/x-www-form-urlencoded"))
		    output_stream = new ByteArrayOutputStream(300);
		else
		    output_stream = new HttpOutputStream();
	    }

	    if (output_stream instanceof HttpOutputStream)
		connect();
	}

	return output_stream;
    }


    /**
     * Gets the url for this connection. If we're connect()'d and the request
     * was redirected then the url returned is that of the final request.
     *
     * @return the final url, or null if any exception occured.
     */
    public URL getURL()
    {
	if (connected)
	{
	    try
		{ return resp.getEffectiveURI().toURL(); }
	    catch (Exception e)
		{ return null; }
	}

	return url;
    }


    /**
     * Sets the <var>If-Modified-Since</var> header.
     *
     * @param time the number of milliseconds since 1970.
     */
    public void setIfModifiedSince(long time)
    {
	super.setIfModifiedSince(time);
	setRequestProperty("If-Modified-Since", Util.httpDate(new Date(time)));
    }


    /**
     * Sets an arbitrary request header.
     *
     * @param name  the name of the header.
     * @param value the value for the header.
     */
    public void setRequestProperty(String name, String value)
    {
	Log.write(Log.URLC, "URLC:  (" + urlString + ") Setting request property: " +
			    name + " : " + value);

	int idx;
	for (idx=0; idx<headers.length; idx++)
	{
	    if (headers[idx].getName().equalsIgnoreCase(name))
		break;
	}

	if (idx == headers.length)
	    headers = Util.resizeArray(headers, idx+1);

	headers[idx] = new NVPair(name, value);
    }


    /**
     * Gets the value of a given request header.
     *
     * @param name  the name of the header.
     * @return the value part of the header, or null if no such header.
     */
    public String getRequestProperty(String name)
    {
	for (int idx=0; idx<headers.length; idx++)
	{
	    if (headers[idx].getName().equalsIgnoreCase(name))
		return headers[idx].getValue();
	}

	return null;
    }


    /**
     * Sets an arbitrary default request header. All headers set here are
     * automatically sent with each request.
     *
     * @param name  the name of the header.
     * @param value the value for the header.
     */
    public static void setDefaultRequestProperty(String name, String value)
    {
	Log.write(Log.URLC, "URLC:  Setting default request property: " +
			    name + " : " + value);

	int idx;
	for (idx=0; idx<default_headers.length; idx++)
	{
	    if (default_headers[idx].getName().equalsIgnoreCase(name))
		break;
	}

	if (idx == default_headers.length)
	    default_headers = Util.resizeArray(default_headers, idx+1);

	default_headers[idx] = new NVPair(name, value);
    }


    /**
     * Gets the value for a given default request header.
     *
     * @param name  the name of the header.
     * @return the value part of the header, or null if no such header.
     */
    public static String getDefaultRequestProperty(String name)
    {
	for (int idx=0; idx<default_headers.length; idx++)
	{
	    if (default_headers[idx].getName().equalsIgnoreCase(name))
		return default_headers[idx].getValue();
	}

	return null;
    }


    /**
     * Enables or disables the automatic handling of redirection responses
     * for this instance only. Cannot be called after <code>connect()</code>.
     *
     * @param set enables automatic redirection handling if true.
     */
    public void setInstanceFollowRedirects(boolean set)
    {
	if (connected)
	    throw new IllegalStateException("Already connected!");

	do_redir = set;
    }


    /**
     * @return true if automatic redirection handling for this instance is
     *              enabled.
     */
    public boolean getInstanceFollowRedirects()
    {
	return do_redir;
    }


    /**
     * Connects to the server (if connection not still kept alive) and
     * issues the request.
     */
    public synchronized void connect()  throws IOException
    {
	if (connected)  return;

	Log.write(Log.URLC, "URLC:  (" + urlString + ") Connecting ...");

	// useCaches TBD!!!

	synchronized (con)
	{
	    con.setAllowUserInteraction(allowUserInteraction);
	    if (do_redir)
		con.addModule(redir_mod, 2);
	    else
		con.removeModule(redir_mod);

	    try
	    {
		if (output_stream instanceof ByteArrayOutputStream)
		    resp = con.ExtensionMethod(method, resource,
			((ByteArrayOutputStream) output_stream).toByteArray(),
					     headers);
		else
		    resp = con.ExtensionMethod(method, resource,
				    (HttpOutputStream) output_stream, headers);
	    }
	    catch (ModuleException e)
		{ throw new IOException(e.toString()); }
	}

	connected = true;
    }


    /**
     * Closes all the connections to this server.
     */
    public void disconnect()
    {
	Log.write(Log.URLC, "URLC:  (" + urlString + ") Disconnecting ...");

	con.stop();
    }


    /**
     * Shows if request are being made through an http proxy or directly.
     *
     * @return true if an http proxy is being used.
     */
    public boolean usingProxy()
    {
	return (con.getProxyHost() != null);
    }


    /**
     * produces a string.
     * @return a string containing the HttpURLConnection
     */
    public String toString()
    {
	return getClass().getName() + "[" + url + "]";
    }
}
