package nxm.sys.net;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.PrintStream;
import java.net.HttpURLConnection;
import java.net.Socket;
import java.text.SimpleDateFormat;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.regex.Pattern;

import nxm.sys.inc.AsciiMap;
import nxm.sys.inc.Installation;
import nxm.sys.inc.InternalUseOnly;
import nxm.sys.inc.MidasReference;
import nxm.sys.inc.ProvisionalUseOnly;
import nxm.sys.lib.BaseFile;
import nxm.sys.lib.Command;
import nxm.sys.lib.Convert;
import nxm.sys.lib.IOResource;
import nxm.sys.lib.Midas;
import nxm.sys.lib.MidasException;
import nxm.sys.lib.Shell;
import nxm.sys.lib.Table;
import nxm.sys.lib.Time;

/** Class to parse an HTTP request, and provide handles and helper methods
    for an HSource class to respond to the request.

   @author Jeff Schoen / Neon Ngo
 */
public class HPage implements Runnable, AsciiMap {
  private static boolean DEBUG = false; // Turns debug messages on/off.

  private static final Pattern DISP_SEP = Pattern.compile("[ ]*;[ ]*");
  /** RFC 1123 date/time pattern use by HTTP    1.1 (RFC 2616) e.g. Sun, 06 Nov 1994 08:49:37 GMT */
  private static final String  RFC1123_DATE_PATTERN = "EEE, dd MMM yyyy HH:mm:ss 'GMT'";
  /** RFC 850 date/time pattern use by some HTTP 1.0 client/servers (obsolete) e.g. Sunday, 06-Nov-94 08:49:37 GMT */
  private static final String  RFC850_DATE_PATTERN = "EEEE, dd-MMM-yy HH:mm:ss 'GMT'";
  /** ANSI C's asctime() format use by some HTTP 1.0 client/servers (obsolete) e.g. Sun Nov  6 08:49:37 1994 */
  private static final String  ASCTIME_DATE_PATTERN = "EEE MMM d HH:mm:ss yyyy";
  /** NOTE: DateFormat is NOT thread safe hence it is NOT a static variable */
  private final SimpleDateFormat httpDateFormat = new SimpleDateFormat(RFC1123_DATE_PATTERN);

  /** HTTP header end-of-line string (CR+LF). */
  public static final String EOLSTR = "\r\n";
  /** HTTP header end-of-line (as bytes). */
  public static final byte[] EOL = EOLSTR.getBytes();

  /** The server associated with the request. */
  public final HServer server;

  private Table               reqHeader;                 // Request (input) header
  private Table               resHeader;                 // Response (output) header
  private MultipartData[]     postData;                  // Any data associated with a POST request (input)
  private Socket              sock;                      // Associated socket
  private BufferedOutputStream os;                       // Output stream (response)
  private BufferedInputStream is;                        // Input stream (request)
  private String              method;                    // HTTP method requested ("GET", "POST", etc.)
  private String              uri;                       // HTTP URI requested
  private String              version;                   // HTTP version requested
  private int                 status = HttpURLConnection.HTTP_BAD_REQUEST; // Output status code
  private boolean             opened = false;            // Has the response been opened?
  private boolean             closed = false;            // Has the response been closed?
  private String              scheme = "US-ASCII";       // Character encoding scheme

  /** HPage Constructor
   *  @param server the HTTP server to listen on
   *  @param sock   the socket for the HTTP server
   *  @throws IOException handles any potential exceptions with the output or input buffer streams
   */
  public HPage (HServer server, Socket sock) throws IOException {
    this(server,sock,"US-ASCII");
  }

  /** HPage Constructor
   *  @param server the HTTP server to listen on
   *  @param sock   the socket for the HTTP server
   *  @param scheme the scheme for accessing web resources
   *  @throws IOException handles any potential exceptions with the output or input buffer streams
   */
  public HPage (HServer server, Socket sock, String scheme) throws IOException {
    reqHeader = null;        // set in parseRequest()
    resHeader = new Table(); // cleared in writeHeader()
    setDate(new Date());     // set server date to help with caching
    // RFC 2616 Sections 3.8 Product Tokens and 14.38 Server (OPTIONAL)
    resHeader.put("Server","NeXtMidas/"+Installation.NM_VERSION+" Java/"+Shell.getJavaVersion());
    setContentType("text/html");
    this.server = server;
    this.sock = sock;
    this.scheme = scheme;
    os = new BufferedOutputStream(sock.getOutputStream()); // since 3.1.0 (wrapped with BufferedOutputStream)
    is = new BufferedInputStream(sock.getInputStream());
  }

  public void run() {
    try {
      parseRequest();
      handleRequest();
      close();
      sock.close();
    }
    catch (Exception e) {
      Shell.printStackTrace("HPAGE: Error processing request URI="+uri, e);
      close();

      try {
        sock.close();
      }
      catch (Exception ex) {
        Shell.printStackTrace("HPAGE: Can not close socket. URI="+uri, ex);
      }
    }
    if (DEBUG) System.out.println("Done "+uri);
  }

  /** Since NeXtMidas 3.1.0 this method is now private, prior to that it was public.
      It was deprecated in NeXtMidas 2.7.0, it should be private.
   */
  private void parseRequest () throws IOException {
    String line = readline();
    if (DEBUG) System.out.println("Req: "+line);
    if (line == null) {
//    throw new IllegalArgumentException("Improperly formatted request line : null"); // prior to 3.2.0
      // 2012-07-18 NTN: it is common for LayerMap to ping this HTTP port with an empty request to
      //                 see if the server is alive, so we can ignore this empty request from client
      return; // since 3.2.0
    }
    int i = line.indexOf(' ');
    int j = line.lastIndexOf(' ');
    if ((i < 0) || (j < 0) || (j <= i)) {
      throw new IllegalArgumentException("Improperly formatted request line : "+line+"  HOST="+sock.getRemoteSocketAddress());
    }
    method    = line.substring(0,i);
    uri       = line.substring(i+1,j);
    version   = line.substring(j+1);
    reqHeader = readHeader();

    if (server.parent != null && uri.indexOf('^')>=0) {
      uri = server.parent.MA.evaluateCarets(uri);
    }
    if (method.equals("POST")) {
      postData = readPost(reqHeader);
    }
  }

  /** Reads the content of a POST request. */
  private MultipartData[] readPost (Table header) throws IOException {
    String contentType   = header.getS("Content-Type",   null);
    String contentLength = header.getS("Content-Length", null);

    int bytes = (contentLength == null)? -1 : Convert.s2l(contentLength);

    byte[] midBoundary = null;
    byte[] endBoundary = null;

    if ((contentType != null) && (contentType.startsWith("multipart/"))) {
      String str = contentType;

      while (str.length() > 0) {
        int i = str.indexOf(';');    if (i < 0) break;
        str = str.substring(i+1).trim();
        int j = str.indexOf('=');    if (j < 0) continue;
        int k = str.indexOf(';',j);  if (k < 0) k = str.length();

        String key = str.substring(0,j).trim();
        String val = str.substring(j+1,k).trim();

        if (key.equalsIgnoreCase("boundary")) {
          // A mid boundary (i.e. between two parts or between the main header and the first part) is
          // specified by a line containing "--BOUND" (where BOUND is the boundary) an end boundary
          // (i.e. the last part) is specified by a line containing "--BOUND--". We use an array rather
          // than a String for performance reasons and because we could be reading through raw binary
          // data that could cause errors if we try to convert it to a String.
          String mid = "--"+val+EOLSTR;
          String end = "--"+val+"--"+EOLSTR;
          midBoundary = mid.getBytes();
          endBoundary = end.getBytes();
          break;
        }
      }
    }

    // Simple byte read, not multi-part
    if (midBoundary == null) {
      if (bytes < 0) bytes = is.available();

      byte[] buf = new byte[bytes];
      int off = 0;
      int len = bytes;

      while (len > 0) {
        int read = is.read(buf, off, len);
        if (read == 0) { Time.sleep(0.1); continue; }
        if (read <  0) { throw new IOException("End of data before boundary reached."); }
        off+=read;
        len-=read;
      }
      return new MultipartData[] { new MultipartData(header, buf) };
    } // end Simple byte read, not multi-part

    // Multi-part read
    boolean done = readHeaderGap(midBoundary, endBoundary);   // Skip the "gap"

    // Read each part
    ArrayList<MultipartData> parts = new ArrayList<>();
    while (!done) {
      Table  head = readHeader();    // Read the sub-header
      String ct   = head.getS("Content-Type", null);

      if ((ct != null) && (ct.startsWith("multipart/"))) {
        MultipartData[] mpd = readPost(head);             // Read multipart
        parts.add(new MultipartData(head,mpd));
        done = readHeaderGap(midBoundary, endBoundary);   // Skip the "gap"
        continue;
      }

      // Read the data
      if (DEBUG) System.out.println("Dat: <data>");
      byte[] data = new byte[4096];
      int    len  = 0;
      while (true) {
        byte[]  line = readln();
        boolean mid  = Arrays.equals(line, midBoundary);
        boolean end  = Arrays.equals(line, endBoundary);

        if (mid || end) {
          parts.add(new MultipartData(head,data,0,len-2)); //-2 to remove last CR+LF
          done = end;
          break;
        }

        if (len+line.length > data.length) {
          int    lgth = (len+line.length+4095)/4096*4096; // round up to nearest 4096
          byte[] temp = new byte[lgth];
          System.arraycopy(data, 0, temp, 0, len);
          data = temp;
        }
        System.arraycopy(line, 0, data, len, line.length);
        len += line.length;
      }
    }
    return parts.toArray(new MultipartData[0]);
  }

  /** Gets the data associated with an HTTP POST.
      @return The multi-part data, if given a POST that is not multi-part this will be an array of
              length 1 holding the data posted. (This will return null for any non-POST request.)
      @since NeXtMidas 2.7.0
   */
  public MultipartData[] getPostData () {
    return postData;
  }

  /** Gets the named data associated with an HTTP POST. This explicitly checks looks for a
      <code>Content-Disposition</code> that is <code>form-data</code> with <code>name="&lt;name&gt;"</code>.
      @param name The name of the form-data entry to get.
      @return The multi-part data that matches the given name or null if not found. (This will
              return null for any non-POST request.)
      @since NeXtMidas 2.7.0
   */
  public MultipartData getPostData (String name) {
    if (postData == null) return null;
    for (MultipartData mpd : postData) {
      String disp = mpd.getHeaderValue("Content-Disposition");

      if ((disp != null) && disp.startsWith("form-data;")) {
        String[] params = DISP_SEP.split(disp);
        for (int i = 1; i < params.length; i++) { // skip 0 since it is "form-data"
          if (params[i].startsWith("name=\"") && params[i].endsWith("\"")) {
            String given = params[i].substring(6, params[i].length()-1);
            if (name.equals(given)) return mpd;
          }
        }
      }
    }
    return null; // not found
  }

  /** Gets the HTTP request method used.
      @return The request method ("GET", "POST", etc.).
      @since NeXtMidas 2.7.0
   */
  public String getMethod () {
    return method;
  }

  /** Gets the set of parameters passed in on the URI. Examples:
      <pre>
        "http://localhost/"               = { }
        "http://localhost/?one=1"         = {ONE="1"}
        "http://localhost/?one=1&amp;two=2"   = {ONE="1",TWO="2"}
        "http://localhost/?str=a+b+c"     = {STR="a b c"}
      </pre>
      For a POST request, this will search through the content passed and will merge in any
      parameters specified with a MIME type of "application/x-url-encoded" or
      "application/x-www-form-urlencoded". (If a Content-Disposition is specified for multi-part
      data, a check is also made to verify that it is "form-data".) <br>
      <br>
      Note that all keys will be converted to UPPERCASE while the values remain Case-Sensitive. If
      duplicate keys are found, only the value of the LAST one is used.
      @return The parameters passed in the client request.
   */
  public Table getParameters () {
    String decodedURI = uri;
    try {
      decodedURI = java.net.URLDecoder.decode(uri, scheme);
    } catch (Exception e) { /* use encoded as before*/  }
    Table tbl = HSource.getParameters(decodedURI, null);

    if (postData != null) {
      for (MultipartData mpd : postData) {
        String disp = mpd.getHeaderValue("Content-Disposition");
        String mime = mpd.getHeaderValue("Content-Type");
        byte[] data = mpd.getData();
        if (mime == null) continue;
        if (data == null) continue;
        if (data.length == 0) continue;

        if ((disp == null) || disp.startsWith("form-data;")) {
          if (mime.equals("application/x-url-encoded") || mime.equals("application/x-www-form-urlencoded")) {
            String params = "?"+new String(data);
            HSource.getParameters(params, tbl);
          }
        }
      }
    }
    return tbl;
  }

  /** Since NeXtMidas 3.1.0 this method is now private, prior to that it was public.
      It was deprecated in NeXtMidas 2.7.0, it should be private.
   */
  private void handleRequest () throws IOException {
    if ("GET".equals(method) || "HEAD".equals(method) || "POST".equals(method)) {
      HSource ds = server.getSource(uri);
      if (DEBUG) Shell.writeln("HPage.handleRequest() uri="+uri+" ds="+ds);
      if (ds==null) {
        setStatus(HttpURLConnection.HTTP_NOT_FOUND);
        open();
        writeNotFound();
      }
      else if (method.equals("HEAD") && !(ds instanceof HFiles || ds instanceof HFile)) { // NTN: let HFile & HFiles handle HEAD request
        // return headers only and no content as specified in HTTP 1.0/1.1 RFC
        setStatus(HttpURLConnection.HTTP_OK);
      }
      else {
        setStatus(HttpURLConnection.HTTP_OK);
        ds.handleRequest(uri,this);
      }
    }
    else {
      setStatus(HttpURLConnection.HTTP_NOT_IMPLEMENTED);
    }
  }

  /** Sets the HTTP status. This must be called before open().
      <pre>
        Warning: HTTP 1.1 requires that "All 1xx (informational), 204 (no content), and
                 304 (not modified) responses MUST NOT include a message-body" (RFC 2616,
                 ftp://ftp.isi.edu/in-notes/rfc2616.txt).

                 If the status is set to one of these codes, the content length must be
                 set to 0. Failure to set the content length may cause strange errors on
                 some clients, including an occasional "java.net.SocketException:
                 Connection reset" exception on some Java clients.
      </pre>
      @param status One of the valid HTTP status codes from {@link HttpURLConnection} such as
                    <code>HTTP_OK</code> or <code>HTTP_NOT_FOUND</code>.
   */
  public void setStatus (int status) {
    this.status = status;
  }

  /** Enable/disable debugging
   *  @param val true to enable debugging
   *  @since NeXtMidas 2.7.1
   */
  public static void    setDebug (boolean val) { DEBUG = val;  }
  /** Gets state of NeXtMidas debugging
   *  @return the state of NeXtMidas debugging
   */
  public static boolean getDebug ()            { return DEBUG; }

  /** Get the HTTP URI requested by client.
     @return HTTP URI String requested by client
     @since NeXtMidas 2.7.1
   */
  public String getURI () { return this.uri; }

  /** Has the output socket been opened?
   *  @return output socket been opened */
  public boolean isOpened () { return opened; }

  /** Has the output socket been closed?
   *  @return true if the output socket been closed */
  public boolean isClosed () { return closed; }

  /** Opens the output socket, same as open(). */
  public void openSocket () { open(); }

  /** Opens the output socket, same as close(). */
  public void closeSocket () { close(); }

  /** Opens the output socket. From a macro use {@link #openSocket()}. */
  public void open () {
    if (!opened) {
      writeHeader();
      flush();
      opened = true;
    }
  }

  /** Closes the output socket. From a macro use {@link #closeSocket()}. */
  public void close () {
    if (!closed) {
      if (!opened) { open(); } // Checks to see that it has been opened
      flush();
      closed = true;
      if (is!=null) {
        try { is.close(); } catch (IOException ioe) {} // ignore any IOExceptions
      }
      if (os!=null) {
        try { os.close(); } catch (IOException ioe) {} // ignore any IOExceptions
      }
    }
  }

  /** Calls {@link #openUpToBody(String)} to open the output socket and write out the web page header information,
   *  stopping just before the body of the web page
   *  @param title the title of the web page
   */
  public void openToBody (String title) {
    openUpToBody(title);
    writeln("<body>");
  }
  /** Opens the output socket and writes out the web page header information, stopping just before the body of the web page
   *  @param title the title of the web page
   */
  @ProvisionalUseOnly("Added in NeXtMidas 3.6.0")
  public void openUpToBody (String title) {
    open();
    writeln("<html>");
    writeln("<head>");
    if (title != null) writeln("<title>"+title+"</title>");
    writeln("<meta http-equiv=\"Content-Type\" content=\"text/html; charset=UTF-8\">");
    writeln("<meta http-equiv=\"Pragma\" content=\"no-cache\">"); // since 3.6.0 for Bug 2847
    String hdir = server.getHomeDir();
    if (hdir!=null) {
      writeln("<link rel=\"stylesheet\" type=\"text/css\" href=\""+hdir+"/style.css\">");
    }
    writeln("</head>");
  }

  /** Finishes the web page and calls {@link #close()} to close the output socket */
  public void closeFromBody () {
    writeln("</body>");
    writeln("</html>");
    close();
  }

  private String WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";

  /** Opens a WebSocket
   *  @param protocol the WebSocket protocol
   */
  @ProvisionalUseOnly("Added in NeXtMidas 3.6.0")
  public void openWS (String protocol) {
    String key = getHeader("Sec-WebSocket-Key");
    String hash = Convert.encodeBase64(Convert.getSHA1(key+WS_GUID));
    write("HTTP/1.1 101 Switching Protocols"+EOLSTR);
    write("Upgrade: websocket"+EOLSTR);
    write("Connection: upgrade"+EOLSTR);
    write("Sec-WebSocket-Accept: "+hash+EOLSTR);
    write("Sec-WebSocket-Protocol: "+protocol+EOLSTR);
    write(EOL);
    flush();
    opened = true;
    resHeader = null;
  }
  /** Calls {@link #close()} to close the WebSocket */
  @ProvisionalUseOnly("Added in NeXtMidas 3.6.0")
  public void closeWS () {
    close();
  }
  byte[] m = new byte[4];
  /** Receive data from the WebSocket
   *  @param b     destination buffer
   *  @param off   offset at which to start storing bytes
   *  @param bytes the number of bytes to read (not used)
   *  @return the length of the data received
   */
  @ProvisionalUseOnly("Added in NeXtMidas 3.6.0")
  public int recvWS (byte[] b, int off, int bytes) {
    int i,d,op,len=0;
    try {
      d = is.read();
      if (d<0) return -1;
      op = d&0x7F;
      d = is.read();
      len = d&0x7F;
      boolean masked = (d&0x80)!=0;
      if (len==126) for (len=i=0; i<2; i++) len = (len<<8) | is.read();
      if (len==127) for (len=i=0; i<8; i++) len = (len<<8) | is.read();
      if (masked) for (i=0; i<4; i++) m[i]=(byte)is.read();
      is.read(b,off,len);
      if (masked) for (i=0; i<len; i++) b[i]=(byte)(b[i]^m[i%4]);
      switch (op) {
      case 0x00:        // continuation
        break;
      case 0x01:        // text
        break;
      case 0x02:        // binary
        break;
      case 0x08:        // close
        break;
      case 0x09:        // ping
        break;
      case 0x0A:        // pong
        break;
      }
    }
    catch (Exception e) {  }
    return len;
  }
  /** Send data over the WebSocket
   *  @param b     data to write
   *  @param off   the start offset in the data
   *  @param bytes the number of bytes to write
   *  @return the number of bytes written
   */
  @ProvisionalUseOnly("Added in NeXtMidas 3.6.0")
  public int sendWS (byte[] b, int off, int bytes) {
    int i=0;
    m[i++]=(byte)(0x82);        // FIN binary
    if (bytes<126) {
      m[i++]=(byte)bytes;       // 8b len - nomask
    } else {
      m[i++]=(byte)(126);       // 16b len - no mask
      m[i++]=(byte)((bytes>>8)&0xFF);
      m[i++]=(byte)((bytes>>0)&0xFF);
    }
    try {
      os.write(m,0,i);          // header
      os.write(b,off,bytes);    // data
      os.flush();
    } catch (Exception e) { bytes=0; }
    return bytes;
  }

  /** Flush the output stream. */
  public void flush () {
    try {
      os.flush();
    } catch (IOException e) {
      String emsg = e.getMessage();
      if      ("Connection reset".equals(emsg)) { } // ignore
      else if (emsg.startsWith("Broken pipe"))      { } // ignore
      else if (emsg.startsWith("Connection or outbound has closed")) { } // ignore
      else {
        Shell.getSharedMidasContext().warning("HPAGE Error while flushing uri="+uri+": "+e);
      }
    }
  }

  /** Sets the content type of the response. Must be called before the response is opened.
      @param type The MIME type of the content.
   */
  public void setContentType (String type) {
    resHeader.put("Content-Type",type);
  }

  /** Sets the content length of the response. Must be called before the response is opened.
      @param bytes The content length in bytes.
   */
  public void setContentLength (long bytes) {
    resHeader.put("Content-Length",Long.toString(bytes));
  }

  /** Sets the "Content-Range" of the response (HTTP 1.1 RFC 2616 section 14.16) for a response
      to a Range (partial content) request. This will also set the HTTP response status
      to code 206 (Partial content). If totalLength is zero or less, then response
      status code will be set to 416 (Request range not satisfiable).
      Must be called before the response is opened.
      @param firstBytePos absolute offset of first byte position of range
      @param lastBytePos absolute offset of last byte position of range request
      @param totalLength total number of bytes of media (can be negative if cannot be determined)
      @since NeXtMidas 2.8.3
   */
  void setContentRange (long firstBytePos, long lastBytePos, long totalLength) {
    String instanceLength;
    if (totalLength < 0) {  // unknown total length
      instanceLength = "*"; // SHOULD per RFC 2616 Section 14.16 Content-Range
    } else {
      instanceLength = Long.toString(totalLength);
    }
    String rangeResponse;
    if (firstBytePos > lastBytePos) { // invalid lastBytePos
      rangeResponse = "*";            // SHOULD per RFC 2616 Section 14.16 Content-Range
      setStatus(416);                 // SHOULD per RFC 2616 Section 10.4.17 416 (Requested range not satisfiable)
    } else {
      rangeResponse = firstBytePos+"-"+lastBytePos;
      setStatus(HttpURLConnection.HTTP_PARTIAL); // MUST per RFC 2616 Section14.35.2 Range Retrieval Requests
    }
    resHeader.put("Content-Range", "bytes "+rangeResponse+"/"+instanceLength);
  }

  /** Sets the date and time of the server in the response header.
      This is used by some HTTP clients along with Last-Modified date/time
      to determine how to cache contents returned by this server.
      Must be called before the response is opened.
      @param date The last modified date and time
      @since NeXtMidas 2.7.1
   */
  void setDate (Date date) {
    String datetimestr;
    synchronized (httpDateFormat) { // Needed b/c DateFormat is not thread-safe
      datetimestr = httpDateFormat.format(date);
    }
    resHeader.put("Date", datetimestr);
  }

  /** Sets the last modified date &amp; time of the response.
      Must be called before the response is opened.
      @param date The last modified date and time
      @since NeXtMidas 2.7.1
   */
  public void setLastModified (Date date) {
    String datetimestr;
    synchronized (httpDateFormat) { // Needed b/c DateFormat is not thread-safe
      datetimestr = httpDateFormat.format(date);
    }
    resHeader.put("Last-Modified", datetimestr);
  }

  /** Gets the {@link Table} holding the HTTP Request Header information
   *  @return the table
   */
  public Table getRequestHeader() {
    return reqHeader;
  }

  /** Gets a named value from the request header (null if not found).
   *  @param key name of value in request header
   *  @return the value as a String
   */
  public String getHeader (String key) {
    if (reqHeader == null) return null;
    return reqHeader.getS(toTitleCase(key));
  }

  /** Calls {@link nxm.sys.lib.Table#dump()} to dump the contents of the request header to System.out */
  @InternalUseOnly("Since added in NeXtMidas 3.6.0")
  public void dumpHeader() {
    reqHeader.dump();
  }

  /** Gets a named value from the request header (-1.0 if not found).
   *  @param key name of value in request header
   *  @return the value
   */
  public double getHeaderD (String key) {
    String value = getHeader(key);
    if (value==null) return -1.0;
    int i=value.indexOf('='), j=value.indexOf('-');
    return Convert.s2d(value.substring(i+1,j));
  }

  /** The header keys are case-insensitive. To make them easier to work with, use a consistent
      case-sensitive version. Here we choose to use Title-Case since that is the most common.
      @param key The key to convert to title case.
      @return The key with the first letter in UPPERCASE, every letter following a dash in
              UPPERCASE, and the rest in lowercase.
   */
  private static String toTitleCase (String key) {
    if ((key == null) || (key.length() == 0)) return key;

    char[] chars = key.toCharArray();
    chars[0] = Character.toUpperCase(chars[0]);
    for (int i = 1; i < chars.length; i++) {
      if (chars[i-1] == '-') {
        chars[i] = Character.toUpperCase(chars[i]);
      }
      else {
        chars[i] = Character.toLowerCase(chars[i]);
      }
    }
    return new String(chars);
  }

  /** Writes the output header. */
  private void writeHeader () {
    if (resHeader==null) return;
    int bytes;
    // space after 'status' is required in HTTP 1.0/1.1 RFC
    bytes = write("HTTP/1.1 " + status + " " + EOLSTR);
    if (bytes > 0) for (String key : resHeader.getKeys()) {
      bytes = write(key + ": " + resHeader.getS(key) + EOLSTR);
      if (bytes < 0) break; // since 3.2.0: break out of loop on write error
    }
    if (bytes > 0) write(EOL);
    resHeader = null;
  }

  /** Writes text to the output socket.
      @param line The text to write.
      @return Number of bytes written (&lt;0 on error).
      @see #writeln(String)
   */
  public int write (String line) {
   return write(line.getBytes(),0,-1);
  }

  /** Writes text to the output socket followed by an EOL (end-of-line). HTTP requests use a CR+LF
      end-of-line combination and this method follows that convention.
      @param line The text to write.
      @return Number of bytes written (&lt;0 on error).
   */
  public int writeln (String line) {
   int stat = write(line.getBytes(),0,-1);
   if (stat < 0) return stat;
   return stat + write(EOL,0,EOL.length);
  }

  /** Writes a file to the output socket. This will do the following:
      <pre>
        (1) Open the named file
        (2) Set the Content-Type (i.e. MIME type) of the response
            (see {@link BaseFile#getMimeType} and {@link #setContentType})
        (3) Set the Content-Length of the response in bytes
            (see {@link IOResource#getLength} and {@link #setContentLength})
        (4) Open the socket (see {@link #open})
        (5) Read the raw bytes of the file (see {@link IOResource#read}) and
            write them to the socket (see {@link #write(byte[],int,int)})
        (6) Close the socket (see {@link #close})
        (7) Close the file
      </pre>
      Note that this method assumes that the given file is the complete response to the given
      request and handles it as such. Use of this method is entirely optional and users are
      free to use the other methods in this class for writing out data.
      @param ref   The reference (usually a {@link Command} or {@link Midas} context).
      @param fname The file name. The file must exist and be a static file that can be read for
                   for output over the socket. (Note that the file could have been generated in
                   response to this request and could even exist in RAM, it just needs to be
                   "static" at the point when this method is called.)
      @since NeXtMidas 2.7.0
   */
  public void writeFile (Object ref, Object fname) {
    // Use a basic BaseFile since we need to work with raw bytes, and want to avoid any
    // file-type-specific header/formatting issues.
    BaseFile file;
    if (ref instanceof MidasReference) {
      file = new BaseFile((MidasReference) ref, fname);
    } else {
      file = new BaseFile(Convert.ref2Midas(ref), fname);
    }

    file.open();
    long   len = (long)file.getSize();
    byte[] buf = new byte[BaseFile.BUFFER_SIZE];

    setContentType(BaseFile.getMimeType(ref, fname));
    setContentLength(len);
    open();
    file.io.seek(0L); // make sure it is at the start of the file

    while (len > 0) {
      int toRead  = Math.min(buf.length, (int)Math.min(Integer.MAX_VALUE, len));
      int toWrite = file.read(buf, 0, toRead);

      if (toWrite < 0) {
        throw new MidasException("End of file reached "+len+" bytes before end of file was expected.");
      }
      write(buf,0,toWrite);
      len = len - toWrite;
    }
    close();
    file.close();
  }

  /** Writes data to the output socket. This is the same as <code>write(buf, 0, buf.length)</code>.
      @param buf The data buffer.
      @return Number of bytes written (&lt;0 on error).
   */
  public int write (byte[] buf) {
    return write(buf, 0, buf.length);
  }

  /** Writes data to the output socket.
      @param buf The data buffer.
      @param off The offset into the buffer (usually 0).
      @param len Number of bytes to write out (-1 -&gt; len=buf.length).
      @return Number of bytes written (&lt;0 on error).
   */
  public int write (byte[] buf, int off, int len) {
    if (len<0) len = buf.length;
    try {
      os.write(buf,off,len);
      return len; // since 3.2.0:
    }
    catch (IOException e) {
      if (e.getMessage().startsWith("Connection reset")) { } // ignore
      else if (e.getMessage().startsWith("Broken pipe")) { } // ignore
      else Shell.getSharedMidasContext().printStackTrace("HPage err: "+e+" on "+uri, e);
      return -1;
    }
//  return 0; // prior to 3.2.0
  }

  /** Reads in a header. */
  private Table readHeader () throws IOException {
    String line = readline();
    Table  head = new Table();

    while ((line != null) && (line.length() > 0)) {
      if (DEBUG) System.out.println("Hdr: "+line);
      int index = line.indexOf(':');
      if (index > 0) {
        String key = line.substring(0,index).trim();
        String val = line.substring(index+1).trim();

        head.put(toTitleCase(key), val);
      }
      line = readline();
    }
    if (line == null) throw new IOException("Got EOF before end of header");

    return head;
  }

  /** Reads in the mandatory gap between the header and the start of a multi-part block. */
  private boolean readHeaderGap (byte[] midBoundary, byte[] endBoundary) throws IOException {
    while (true) {
      byte[] line = readln();
      if (line == null) throw new IOException("End of data before boundary reached.");
      if (DEBUG) System.out.println("Gap: "+new String(line));
      if (Arrays.equals(line, midBoundary)) return false;
      if (Arrays.equals(line, endBoundary)) return true;
    }
  }

  /** Write out a byte-array output stream to the buffered output stream
   *  @param baos the byte-array output stream
   */
  public void write (ByteArrayOutputStream baos) {
    try { baos.writeTo(os); }
    catch (IOException e) {
      Shell.getSharedMidasContext().printStackTrace("HPage err: "+e+" on "+uri, e);
    }
  }

  /** Convenient method to write "404 Not Found" HTML to output for requested URI.
      If "HEAD" request method, then this method WILL NOT output the HTML content (per RFC 2616).
      Note: the <code>{@link #setStatus(int) setStatus(HttpURLConnection.HTTP_NOT_FOUND)}</code> and
      {@link #open()} methods SHOULD normally be called before this.
      @since NeXtMidas 2.9.0
   */
  public void writeNotFound () {
    if (method.equals("HEAD")) { return; } // DO NOT return content if HEAD request
    StringBuilder sb = new StringBuilder(256); // start with a 256 initial capacity.
    sb.append("<html><head><title>404 Not Found</title></head>\n");
    sb.append("<body><h1>Not Found</h1>\n");
    sb.append("<p>The requested URL <code>").append(this.uri).append("</code> was not found on this server.</p>\n");
    sb.append("</body></html>");
    writeln(sb.toString());
  }

  /** Reads a line in, does not preserve the ending CR+LF.
      Since NeXtMidas 3.1.0 this method is now private, prior to that it was public.
      It was deprecated in NeXtMidas 2.7.0, it should be private.
   */
  private String readline () throws IOException {
    byte[] line = readln();
    if (line == null) return null;
    String str = new String(line);
    if (str.endsWith(EOLSTR)) str = str.substring(0, str.length()-2);
    return str;
  }

  /** Reads a line in, but preserve the ending CR+LF. Returns bytes for performance reasons. */
  private byte[] readln () throws IOException {
    byte[] buf = new byte[4096];
    int    len = 0;
    int    b; // byte read in (-1 = EOF)

    while ((b = is.read()) >= 0) {
      if (len+2 > buf.length) { // increase size of array
        byte[] temp = new byte[buf.length *2]; // double buffer size
        System.arraycopy(buf, 0, temp, 0, len);
        buf = temp;
      }

      // If the character is the first half of a CRLF pair, read the next
      // byte. If that is an LF then break out of the reading loop. If it's
      // not, then add both characters to the string and go on.
      if (b == CR) {
        int b2 = is.read();
        buf[len++] = (byte)b;
        buf[len++] = (byte)b2;
        if (b2 == LF) break;
      }
      // If we've gotten here, simply add the character to the string
      else {
        buf[len++] = (byte)b;
      }
    }
    if ((b < 0) && (len == 0)) return null; // EOF

    byte[] line = new byte[len];
    System.arraycopy(buf, 0, line, 0, len);
    return line;
  }

  /** Gets the input stream for reading from the socket connection.
      @return The input stream for reading from the socket connection.
      @since NeXtMidas 1.8
   */
  public BufferedInputStream getInputStream () {
    return this.is;
  }

  /** Gets the output stream for writing to the socket connection. <br>
      Note that the {@link PrintStream} returned will be set to terminate all
      lines with the <code>CR&nbsp;+&nbsp;LF</code> sequence, {@link #EOLSTR},
      as is required for HTTP output streams.
      @return The output stream for writing to the socket connection.
      @since NeXtMidas 1.8
   */
  public PrintStream getOutputStream () {
    return new PrintStream(this.os, true) {
      @Override
      public void println() {
        print(EOLSTR);
      }
    };
  }

  /** Gets the socket.
   *  @return the socket */
  public Socket getSocket () {
    return sock;
  }

  /** The data associated with a multi-part request. */
  public static class MultipartData {
    private final Table           header;
    private final byte[]          data;
    private final MultipartData[] mpd;

    /** HPage.MultipartData Constructor
     *  @param header the multi-part header
     *  @param data   the multi-part data
     */
    public MultipartData (Table header, byte[] data) {
      this(header, data, 0, data.length);
    }

    /** HPage.MultipartData Constructor
     *  @param header the input header to copy
     *  @param data   the input data buffer to copy
     *  @param off    the starting offset in which to read the input data buffer
     *  @param len    the amount of data to copy from the input data buffer
     */
    public MultipartData (Table header, byte[] data, int off, int len) {
      if (len < 0) len = 0;

      if ((off != 0) || (len != data.length)) {
        byte[] temp = new byte[len];
        System.arraycopy(data, off, temp, 0, len);
        data = temp;
      }
      this.header = header;
      this.data   = data;
      this.mpd    = null;
    }

    MultipartData (Table header, MultipartData[] mpd) {
      this.header = header;
      this.data   = null;
      this.mpd    = mpd;
    }

    /** Gets a named value from the multi-part header (null if not found).
        @param key The header key.
        @return The associated value of null if not found.
     */
    public String getHeaderValue (String key) {
      if (header == null) return null;
      return header.getS(toTitleCase(key));
    }

    /** Gets the multi-part data as a byte array for any non-multi-part content. The output array
        should be treated as read-only.
        @return The multi-part data (null if {@link #getMultipartData()} is not null).
     */
    public byte[] getData () {
      return data;
    }

    /** Gets the multi-part data as a String for any non-multi-part content. This is the same as
        doing <code>new String(getData())</code>. Note that this method should only be used after
        checking to see that the MIME type supports conversion to a string (i.e. checking to
        see that <code>getHeaderValue("Content-Type")</code> returns <code>"text/plain"</code> or other
        compatible MIME type). <b>The behavior of this method is <u>unspecified</u> if used with
        an incompatible MIME type or invalid data.</b>
        @return The multi-part data (null if {@link #getMultipartData()} is not null).
     */
    public String getDataString () {
      return new String(getData());
    }

    /** Gets the nested multi-part data that is internal to this multi-part block. The output array
        should be treated as read-only.
        @return The multi-part data (null if {@link #getData()} is not null).
     */
    public MultipartData[] getMultipartData () {
      return mpd;
    }
  } // end class MultipartData

  //////////////////////////////////////////////////////////////////////////////////////////////////
  // Deprecated Methods
  //////////////////////////////////////////////////////////////////////////////////////////////////
  /** @deprecated Since NeXtMidas 2.7.0: Replaced by <code>getMethod().equals("HEAD")</code>.
   *  @return true if is HEAD request */
  @Deprecated public boolean isHeadRequest() {
    return method.equals("HEAD");
  }

  /** @deprecated Since NeXtMidas 2.7.0: Replaced by <code>getMethod().equals("GET")</code>.
   *  @return true if is GET request */
  @Deprecated public boolean isGetRequest() {
    return method.equals("GET");
  }

  /** @deprecated Since NeXtMidas 2.7.0: Replaced by {@link #getMethod()}.
   *  @return true if not a request */
  @Deprecated public boolean isNotRequest() {
     return  method.equals("OPTIONS") ||
             method.equals("POST") ||
             method.equals("PUT") ||
             method.equals("DELETE") ||
             method.equals("TRACE") ||
             method.equals("CONNECT");
  }
}
