/*
 * $Id: HTTPQueryHandler.java,v 1.12 2012/03/03 16:35:17 agoubard Exp $
 *
 * See the COPYRIGHT file for redistribution and use restrictions.
 */
package org.xins.common.servlet.container;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.FileNameMap;
import java.net.Socket;
import java.net.URLConnection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicInteger;
import org.apache.http.impl.EnglishReasonPhraseCatalog;

import org.xins.common.MandatoryArgumentChecker;
import org.xins.common.Utils;
import org.xins.common.text.ParseException;

/**
 * HTTP query received to be handled by the servlet.
 *
 * @version $Revision: 1.12 $ $Date: 2012/03/03 16:35:17 $
 * @author <a href="mailto:anthony.goubard@japplis.com">Anthony Goubard</a>
 * @author <a href="mailto:ernst@ernstdehaan.com">Ernst de Haan</a>
 */
class HTTPQueryHandler extends Thread {

   /**
    * The encoding of the request.
    */
   private static final String REQUEST_ENCODING = "ISO-8859-1";

   /**
    * The map containing the MIME type information. Never <code>null</code>
    */
   private static final FileNameMap MIME_TYPES_MAP = URLConnection.getFileNameMap();

   /**
    * The line separator used by the HTTP protocol.
    */
   private static final String CRLF = "\r\n";

   /**
    * The instance number for this created query.
    */
   private static AtomicInteger _instanceNumber = new AtomicInteger();

   /**
    * The socket of the HTTP query.
    */
   private final Socket _client;

   /**
    * Mapping between the path and the servlet.
    */
   private final Map _servlets;

   /**
    * Creates a new HTTPQueryHandler to handle the HTTP query sent by the client.
    *
    * @param client
    *    the connection with the client, cannot be <code>null</code>.
    *
    * @param servlets
    *    the mapping between the path and the servlets, cannot be <code>null</code>.
    *
    * @throws IllegalArgumentException
    *    if <code>client == null || servlets == null</code>.
    */
   public HTTPQueryHandler(Socket client, Map servlets) throws IllegalArgumentException {

      // Check argument
      MandatoryArgumentChecker.check("client", client, "servlets", servlets);

      _client = client;
      _servlets = servlets;
      _instanceNumber.incrementAndGet();
      setName("XINS Query handler #" + _instanceNumber.get());
   }

   public void run() {
      try {
         serviceClient(_client);
      } catch (Exception ex) {

         // If anything goes wrong still continue accepting clients
         Utils.logIgnoredException(ex);
      } finally {
         try {
            _client.close();
         } catch (Throwable exception) {
            // ignore
         }
      }
   }

   /**
    * This method is invoked when a client connects to the server.
    *
    * @param client
    *    the connection with the client, cannot be <code>null</code>.
    *
    * @throws IllegalArgumentException
    *    if <code>client == null</code>.
    *
    * @throws IOException
    *    if the query is not handled correctly.
    */
   public void serviceClient(Socket client)
   throws IllegalArgumentException, IOException {

      // Check argument
      MandatoryArgumentChecker.check("client", client);

      InputStream  inbound  = client.getInputStream();
      OutputStream outbound = client.getOutputStream();

      // Delegate to httpQuery in a way it does not have to bother with
      // closing the streams
      try {
         httpQuery(inbound, outbound);

      // Clean up for httpQuery, if necessary
      } finally{
         if (inbound != null) {
            try {
               inbound.close();
            } catch (Throwable exception) {
               Utils.logIgnoredException(exception);
            }
         }
         if (outbound != null) {
            try {
               outbound.close();
            } catch (Throwable exception) {
               Utils.logIgnoredException(exception);
            }
         }
      }
   }

   /**
    * This method parses the data sent from the client to get the input
    * parameters and format the result as a compatible HTTP result.
    * This method will used the servlet associated with the passed virtual
    * path. If no servlet is associated with the virtual path, the servlet with
    * the virtual path "/" is used as default. If there is no servlet then with
    * the virtual path "/" is found then HTTP 404 is returned.
    *
    * @param in
    *    the input byte stream that contains the request sent by the client.
    *
    * @param out
    *    the output byte stream that must be fed the response towards the
    *    client.
    *
    * @throws IOException
    *    if the query is not handled correctly.
    *
    * @since XINS 1.5.0.
    */
   public void httpQuery(InputStream  in, OutputStream out)
   throws IOException {

      // Read the input
      // XXX: Buffer size determines maximum request size
      char[] buffer = new char[16384];
      BufferedReader inReader = new BufferedReader(new InputStreamReader(in, REQUEST_ENCODING));
      int lengthRead = inReader.read(buffer);
      if (lengthRead < 0) {
         sendBadRequest(out);
         return;
      }
      String request = new String(buffer, 0, lengthRead);
      //byte[] requestBytes = IOReader.readFullyAsBytes(in);
      //String request = new String(requestBytes, 0, requestBytes.length, REQUEST_ENCODING);

      // Read the first line
      int eolIndex = request.indexOf(CRLF);
      if (eolIndex < 0) {
         sendBadRequest(out);
         return;
      }

      // The first line must end with "HTTP/1.0" or "HTTP/1.1"
      String line = request.substring(0, eolIndex);
      request = request.substring(eolIndex + 2);
      if (! (line.endsWith(" HTTP/1.1") || line.endsWith(" HTTP/1.0"))) {
         sendBadRequest(out);
         return;
      }

      // Cut off the last part
      line = line.substring(0, line.length() - 9);

      // Find the space
      int spaceIndex = line.indexOf(' ');
      if (spaceIndex < 1) {
         sendBadRequest(out);
         return;
      }

      // Determine the method
      String method = line.substring(0, spaceIndex);

      // Determine the query string
      String url = line.substring(spaceIndex + 1);
      if ("".equals(url)) {
         sendBadRequest(out);
         return;
      } else if ("GET".equals(method) || "HEAD".equals(method) || "OPTIONS".equals(method)) {
         url = url.replace(',', '&');
      }

      // Normalize the query string
      if ("GET".equals(method) && url.endsWith("/") && getClass().getResource(url + "index.html") != null) {
         url += "index.html";
      }

      // Read the headers
      HashMap inHeaders = new HashMap();
      boolean done = false;
      while (! done) {
         int nextEOL = request.indexOf(CRLF);
         if (nextEOL <= 0) {
            done = true;
         } else {
            try {
               parseHeader(inHeaders, request.substring(0, nextEOL));
            } catch (ParseException exception) {
               sendBadRequest(out);
               return;
            }
            request = request.substring(nextEOL + 2);
         }
      }

      // Determine the body contents
      String body = "".equals(request)
                  ? ""
                  : request.substring(2);


      // Response encoding defaults to request encoding
      String responseEncoding = REQUEST_ENCODING;

      // Handle the case that a web page is requested
      boolean getMethod = method.equals("GET") || method.equals("HEAD");
      String httpResult;
      if (getMethod && url.indexOf('?') == -1 && !url.endsWith("/") && !"*".equals(url)) {
         httpResult = readWebPage(url);

      // No web page requested
      } else {

         // Determine the content type
         String inContentType = getHeader(inHeaders, "Content-Type");

         // If www-form encoded, then append the body to the query string
         if ((inContentType == null || inContentType.startsWith("application/x-www-form-urlencoded")) &&
               body.length() > 0) {
            // XXX: What if the URL already contains a question mark?
            url += '?' + body;
            body = null;
         }

         // Locate the path of the URL
         String virtualPath = url;
         if (virtualPath.indexOf('?') != -1) {
            virtualPath = virtualPath.substring(0, url.indexOf('?'));
         }
         if (virtualPath.endsWith("/") && virtualPath.length() > 1) {
            virtualPath = virtualPath.substring(0, virtualPath.length() - 1);
         }

         // Get the Servlet according to the path
         LocalServletHandler servlet = findServlet(virtualPath);

         // If no servlet is found return 404
         if (servlet == null) {
            sendError(out, "404 Not Found");
            return;
         } else {

            // Query the Servlet
            XINSServletResponse response = servlet.query(method, url, body, inHeaders);

            // Create the HTTP answer
            StringBuffer sbHttpResult = new StringBuffer();
            sbHttpResult.append("HTTP/1.1 " + response.getStatus() + " " +
                  EnglishReasonPhraseCatalog.INSTANCE.getReason(response.getStatus(), Locale.ENGLISH) + CRLF);
            Map<String, String> outHeaders = response.getHeaders();
            for (Map.Entry<String, String> header : outHeaders.entrySet()) {
               String nextHeader = header.getKey();
               String headerValue = header.getValue();
               if (headerValue != null) {
                  sbHttpResult.append(nextHeader + ": " + headerValue + "\r\n");
               }
            }

            String result = response.getResult();
            if (result != null) {
               responseEncoding = response.getCharacterEncoding();
               int length = response.getContentLength();
               if (length < 0) {
                  length = result.getBytes(responseEncoding).length;
               }
               sbHttpResult.append("Content-Length: " + length + "\r\n");
               sbHttpResult.append("Connection: close\r\n");
               sbHttpResult.append("\r\n");
               sbHttpResult.append(result);
            }
            httpResult = sbHttpResult.toString();
         }
      }

      byte[] bytes = httpResult.getBytes(responseEncoding);
      out.write(bytes, 0, bytes.length);
      out.flush();
   }


   /**
    * Finds the servlet that should handle a request at the specified virtual
    * path.
    *
    * @param path
    *    the virtual path, cannot be <code>null</code>.
    *
    * @return
    *    the servlet that was found, or <code>null</code> if none was found.
    *
    * @throws NullPointerException
    *    if <code>path == null</code>.
    */
   private LocalServletHandler findServlet(String path)
   throws NullPointerException {

      // Special case is path "*"
      if ("*".equals(path)) {
         path = "/";
      }

      // If the path does not end with a slash, then add one,
      // to avoid checking that option
      if (path.charAt(path.length() - 1) != '/') {
         path += '/';
      }

      LocalServletHandler servlet;
      do {

         // Find a servlet at this path
         servlet = (LocalServletHandler) _servlets.get(path);

         // If not found, then strip off the last part of the path
         // E.g. "/objects/boats/Cherry"  becomes "/objects/boats/"
         // and  "/objects/boats/Cherry/" becomes "/objects/boats/"
         if (servlet == null) {

            // Remove the trailing slash, if any
            int lastPos = path.length() - 1;
            if (path.charAt(lastPos) == '/') {
               path = path.substring(0, lastPos);
            }

            // Cut up until and including the last slash, if appropriate
            if (path.length() > 0) {
               int i = path.lastIndexOf('/');
               path = path.substring(0, i + 1);
            }
         }

      } while (servlet == null && path.length() > 0);

      return servlet;
   }

   /**
    * Sends an HTTP error back to the client.
    *
    * @param out
    *    the output stream to contact the client.
    *
    * @param status
    *    the HTTP error code status.
    *
    * @throws IOException
    *    if the error cannot be sent.
    */
   private void sendError(OutputStream out, String status)
   throws IOException {
      String httpResult = "HTTP/1.1 " + status + CRLF + CRLF;
      byte[] bytes = httpResult.getBytes(REQUEST_ENCODING);
      out.write(bytes, 0, bytes.length);
      out.flush();
   }

   /**
    * Sends an HTTP bad request back to the client.
    *
    * @param out
    *    the output stream to contact the client.
    *
    * @throws IOException
    *    if the error cannot be sent.
    */
   private void sendBadRequest(OutputStream out)
   throws IOException {
      sendError(out, "400 Bad Request");
   }

   /**
    * Parses an HTTP header.
    *
    * @param headers
    *    the headers already collected.
    *
    * @param header
    *    the line of the header to be parsed.
    *
    * @throws ParseException
    *    if the header is incorrect
    */
   private static void parseHeader(HashMap headers, String header)
   throws ParseException{
      int index = header.indexOf(':');
      if (index < 1) {
         throw new ParseException();
      }

      // Get key and value
      String key   = header.substring(0, index);
      String value = header.substring(index + 1);

      // Always convert the key to upper case
      key = key.toUpperCase();

      // Always trim the value
      value = value.trim();

      // XXX: Only one header supported
      if (headers.get(key) != null) {
         throw new ParseException();
      }

      // Store the key-value combo
      headers.put(key, value);
   }

   /**
    * Gets a HTTP header from the request.
    *
    * @param headers
    *    the list of the headers.
    *
    * @param key
    *    the name of the header.
    *
    * @return
    *    the header value for the specified key or <code>null</code> if the
    *    key is not in the haeders.
    */
   String getHeader(HashMap headers, String key) {
      return (String) headers.get(key.toUpperCase());
   }

   /**
    * Reads the content of a web page.
    *
    * @param url
    *    the location of the content, cannot be <code>null</code>.
    *
    * @return
    *    the HTTP response to return, never <code>null</code>.
    *
    * @throws IOException
    *    if an error occcurs when reading the URL.
    */
   private String readWebPage(String url) throws IOException {
      String httpResult;
      if (getClass().getResource(url) != null) {
         InputStream urlInputStream = getClass().getResourceAsStream(url);
         ByteArrayOutputStream contentOutputStream = new ByteArrayOutputStream();
         byte[] buf = new byte[8192];
         int len;
         while ((len = urlInputStream.read(buf)) > 0) {
            contentOutputStream.write(buf, 0, len);
         }
         contentOutputStream.close();
         urlInputStream.close();
         String content = contentOutputStream.toString("ISO-8859-1");

         httpResult = "HTTP/1.1 200 OK\r\n";
         String fileName = url.substring(url.lastIndexOf('/') + 1);
         httpResult += "Content-Type: " + MIME_TYPES_MAP.getContentTypeFor(fileName) + "\r\n";
         int length = content.getBytes("ISO-8859-1").length;
         httpResult += "Content-Length: " + length + "\r\n";
         httpResult += "Connection: close\r\n";
         httpResult += "\r\n";
         httpResult += content;
      } else {
         httpResult = "HTTP/1.1 404 Not Found\r\n";
      }
      return httpResult;
   }
}