/*
 * @(#)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 org.exoplatform.common.http.client;

import org.exoplatform.commons.utils.ClassLoading;
import org.exoplatform.commons.utils.PrivilegedSystemHelper;
import org.exoplatform.services.log.ExoLogger;
import org.exoplatform.services.log.Log;

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

/**
 * 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(&quot;Content-Length&quot;, ...);
 *   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;

   private static final Log LOG = ExoLogger.getLogger("exo.ws.commons.HttpURLConnection");

   static
   {
      // The default allowUserAction in java.net.URLConnection is
      // false.
      try
      {
         if (Boolean.getBoolean("HTTPClient.HttpURLConnection.AllowUI"))
            setDefaultAllowUserInteraction(true);
      }
      catch (SecurityException se)
      {
         if (LOG.isTraceEnabled())
         {
            LOG.trace("An exception occurred: " + se.getMessage());
         }
      }

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

      // Set the User-Agent if the http.agent property is set
      try
      {
         String agent = PrivilegedSystemHelper.getProperty("http.agent");
         if (agent != null)
            setDefaultRequestProperty("User-Agent", agent);
      }
      catch (SecurityException se)
      {
         if (LOG.isTraceEnabled())
         {
            LOG.trace("An exception occurred: " + se.getMessage());
         }
      }
   }

   // 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 = PrivilegedSystemHelper.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(), pe);
      }
      catch (SecurityException se)
      {
         if (LOG.isTraceEnabled())
         {
            LOG.trace("An exception occurred: " + se.getMessage());
         }
      }

      try
      {
         String host = PrivilegedSystemHelper.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)
      {
         if (LOG.isTraceEnabled())
         {
            LOG.trace("An exception occurred: " + se.getMessage());
         }
      }

      // 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.
    */
   @Override
   public void setRequestMethod(String method) throws ProtocolException
   {
      if (connected)
         throw new ProtocolException("Already connected!");

      if (LOG.isDebugEnabled())
         LOG.debug(urlString + " Setting request method: " + method);

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

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

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

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

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

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

   /**
    * 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.
    */
   @Override
   public String getHeaderField(String name)
   {
      try
      {
         if (!connected)
         {
            connect();
         }
         return resp.getHeader(name);
      }
      catch (IOException e)
      {
         return null;
      }
      catch (ModuleException 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.
    */
   @Override
   public int getHeaderFieldInt(String name, int def)
   {
      try
      {
         if (!connected)
         {
            connect();
         }
         return resp.getHeaderAsInt(name);
      }
      catch (NumberFormatException e)
      {
         return def;
      }
      catch (IOException e)
      {
         return def;
      }
      catch (ModuleException 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.
    */
   @Override
   public long getHeaderFieldDate(String name, long def)
   {
      try
      {
         if (!connected)
         {
            connect();
         }
         return resp.getHeaderAsDate(name).getTime();
      }
      catch (IllegalArgumentException e)
      {
         return def;
      }
      catch (IOException e)
      {
         return def;
      }
      catch (ModuleException 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.
    */
   @Override
   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.
    */
   @Override
   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 en = resp.listHeaders();
         while (en.hasMoreElements())
         {
            num++;
            en.nextElement();
         }

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

         // fill arrays
         en = resp.listHeaders();
         for (int idx = 1; idx < num; idx++)
         {
            hdr_keys[idx] = (String)en.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 (NoSuchElementException e)
      {
         hdr_keys = hdr_values = new String[0];
      }
      catch (IOException e)
      {
         hdr_keys = hdr_values = new String[0];
      }
      catch (ModuleException 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)
    */
   @Override
   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(), e);
      }

      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
    */
   @Override
   public InputStream getErrorStream()
   {
      try
      {
         if (!doInput || !connected || resp.getStatusCode() < 300 || resp.getHeaderAsInt("Content-length") <= 0)
         {
            return null;
         }

         return resp.getInputStream();
      }
      catch (IOException e)
      {
         return null;
      }
      catch (NumberFormatException e)
      {
         return null;
      }
      catch (ModuleException 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 HttpOutputStream
    */
   @Override
   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)
      {
         if (LOG.isDebugEnabled())
            LOG.debug(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.
    */
   @Override
   public URL getURL()
   {
      if (connected)
      {
         try
         {
            return resp.getEffectiveURI().toURL();
         }
         catch (MalformedURLException e)
         {
            return null;
         }
         catch (IOException e)
         {
            return null;
         }
         catch (ModuleException e)
         {
            return null;
         }
      }

      return url;
   }

   /**
    * Sets the <var>If-Modified-Since</var> header.
    * @param time the number of milliseconds since 1970.
    */
   @Override
   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.
    */
   @Override
   public void setRequestProperty(String name, String value)
   {

      if (LOG.isDebugEnabled())
         LOG.debug(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.
    */
   @Override
   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)
   {
      if (LOG.isDebugEnabled())
         LOG.debug("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.
    */
   @Override
   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.
    */
   @Override
   public boolean getInstanceFollowRedirects()
   {
      return do_redir;
   }

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

      if (LOG.isDebugEnabled())
         LOG.debug(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(), e);
         }
      }

      connected = true;
   }

   /**
    * Closes all the connections to this server.
    */
   @Override
   public void disconnect()
   {
      if (LOG.isDebugEnabled())
         LOG.debug(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.
    */
   @Override
   public boolean usingProxy()
   {
      return (con.getProxyHost() != null);
   }

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