/* 
 * E-XML Library:  For XML, XML-RPC, HTTP, and related.
 * Copyright (C) 2002-2008  Elias Ross
 * 
 * genman@noderunner.net
 * http://noderunner.net/~genman
 * 
 * 1025 NE 73RD ST
 * SEATTLE WA 98115
 * USA
 *
 * 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.1 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.
 * 
 * $Id$
 */

package net.noderunner.http;

import java.io.BufferedReader;
import java.io.ByteArrayOutputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.BitSet;
import java.util.HashMap;
import java.util.Map;
import java.util.StringTokenizer;

/**
 * Contains utility functions for common HTTP I/O tasks.
 */
public class HttpUtil
{
	static String CRLF = "\r\n";

	private static byte junk[] = null;
	private static BitSet noencode;
	private static final byte hex[] = {
		'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 
		'A', 'B', 'C', 'D', 'E', 'F'};

	static {
		noencode = new BitSet(256);
		for (int i = 'a'; i <= 'z'; i++)
			noencode.set(i);
		for (int i = 'A'; i <= 'Z'; i++)
			noencode.set(i);
		for (int i = '0'; i <= '9'; i++)
			noencode.set(i);
		noencode.set('-');
		noencode.set('_');
		noencode.set('.');
		noencode.set('*');
	}

	/**
	 * Cannot be instantiated.
	 */
	private HttpUtil() {
	}

	/**
	 * Reads an input stream until EOF.
	 * Returns the number of bytes read.
	 */
	public static int readFully(InputStream stream)
		throws IOException
	{
		if (stream == null)
			throw new IllegalArgumentException("Null stream");
		// not synchronized, but not a big deal
		if (junk == null)
			junk = new byte[1024];

		int total = 0;
		int got = 0;
		while (true) {
			got = stream.read(junk, 0, junk.length);
			if (got == -1)
				return total;
			total += got;
		}
	}

	/**
	 * Reads an input stream until EOF.
	 * Returns the bytes read.
	 */
	public static byte[] read(InputStream stream)
		throws IOException
	{
		byte b[] = new byte[128];
		ByteArrayOutputStream bo = new ByteArrayOutputStream();
		int got = 0;
		while (true) {
			got = stream.read(b, 0, b.length);
			if (got == -1)
				return bo.toByteArray();
			bo.write(b, 0, got);
		}
	}
	
	/**
	 * Returns a character reader for reading HTTP data.
	 * Returns either an <code>InputStreamReader</code>, wrapping
	 * either a {@link ChunkedInputStream} or 
	 * {@link LimitedInputStream}, based on the supplied headers.
	 * If the headers did not indicate data was being sent, returns
	 * an input stream for reading the rest of the document.
	 */
	public static InputStream wrapInputStream(InputStream stream, MessageHeaders hl)
		throws IOException
	{
		if (stream == null)
			throw new IllegalArgumentException("Null stream");
		if (hl == null)
			throw new IllegalArgumentException("Null headers");

		boolean chunked = hl.contains(MessageHeader.MH_TRANSFER_ENCODING_CHUNKED);
		if (chunked)
			return new ChunkedInputStream(stream);

		String s = hl.getFieldContent(MessageHeader.FN_CONTENT_LENGTH);
		if (s == null)
			return stream;

		try {
			int length = Integer.parseInt(s);
			return new LimitedInputStream(stream, length);
		} catch (NumberFormatException e) {
			throw new HttpException("content-length not a number:" + s);
		}
	}

	/**
	 * Returns a single line from a 8-bit <code>InputStream</code>
	 * (which is assumed to be ASCII).
	 */
	public static String readHttpLine(InputStream in)
		throws IOException
	{
		StringBuilder sb = new StringBuilder();
		int c = 0;
		while (true) {
			c = in.read();
			if (c == -1)
				throw new EOFException("EOF processing HTTP request");
			if (c == '\n')
				break;
			if (c == '\r') {
				c = in.read();
				if (c != '\n')
					throw new HttpException("Expected LN character reading HTTP information");
				break;
			}
			sb.append((char)c);
			if (sb.length() > 1024)
				throw new HttpException("Header line too long");
		}
		return sb.toString();
	}


	/**
	 * URL encodes a series of parameters.
	 * Names are followed by their value in the array.  For example:
	 * <pre>
	 * byte[] buf;
	 * buf = HttpUtil.urlEncode(new String[] { "name1", "value1", "name2", "value2" }); 
	 * </pre>
	 * results in the byte array:
	 * <pre>
	 * "name1=value1&amp;name2=value2"
	 * </pre>
	 * Strings are converted to bytes using the given encoding.
	 * @param nvPairs array of name-value pairs, must be
	 * even in length
	 * @param encoding Java encoding, or null to use the default encoding
	 * @return a byte array which can be used in {@link EasyHttpClient#doPostUrlEncoded}
	 * @throws UnsupportedEncodingException if the encoding is invalid
	 */
	public static byte[] urlEncode(String[] nvPairs, String encoding)
		throws UnsupportedEncodingException
	{
		if (nvPairs == null)
			throw new IllegalArgumentException("Null array");
		if (nvPairs.length % 2 != 0)
			throw new IllegalArgumentException("Odd length array");
		ByteArrayOutputStream bb;
		bb = new ByteArrayOutputStream(16 * nvPairs.length);
		for (int i = 0; i < nvPairs.length; i += 2)
			appendNV(bb, nvPairs[i], nvPairs[i + 1], encoding);
		return bb.toByteArray();
	}

	/**
	 * URL encodes a series of parameters, using the 
	 * Strings are converted to the default platform encoding.
	 */
	public static byte[] urlEncode(String[] nvPairs) {
		try {
			return urlEncode(nvPairs, null);
		} catch (UnsupportedEncodingException e) {
			throw new Error("should not be here");
		}
	}

	/**
	 * URL encodes a single value, writing its value to
	 * an output stream.
	 */
	public static void urlEncode(ByteArrayOutputStream os, byte[] buf)
	{
		for (int i = 0; i < buf.length; i++) {
			byte b = buf[i];
			if (noencode.get(b)) {
				os.write(b);
			} else if (b == ' ') {
				os.write((byte)'+');
			} else {
				os.write((byte)'%');
				int low = (int) (b & 0x0f);
				int high = (int) ((b & 0xf0) >> 4);
				os.write(hex[high]);
				os.write(hex[low]);
			}
		}
	}

	/**
	 * Appends a key-value pair to an output stream buffer.
	 */
	private static void appendNV(ByteArrayOutputStream os, String n, String v, String enc)
		throws java.io.UnsupportedEncodingException
	{
		if (os.size() > 0)
			os.write((byte)'&');
		if (enc == null)
			urlEncode(os, n.getBytes());
		else
			urlEncode(os, n.getBytes(enc));
		if (v != null) {
			os.write((byte)'=');
			if (enc == null)
				urlEncode(os, v.getBytes());
			else
				urlEncode(os, v.getBytes(enc));
		}
	}

	/**
	 * URL encodes a <code>Map</code>.
	 * @param map a name-value map of entries to encode
	 * @param encoding Java encoding, or null to use the default encoding
	 * @return a byte buffer which can be used in {@link EasyHttpClient#doPostUrlEncoded}
	 * @throws UnsupportedEncodingException if the encoding is invalid
	 */
	public static byte[] urlEncode(Map<String, String> map, String encoding)
		throws UnsupportedEncodingException
	{
		if (map == null)
			throw new IllegalArgumentException("Null map");
		ByteArrayOutputStream os = new ByteArrayOutputStream();
		for (Map.Entry<String, String> me : map.entrySet()) {
			appendNV(os, me.getKey(), me.getValue(), encoding);
		}
		return os.toByteArray();
	}

	/**
	 * URL encodes a <code>Map</code>.
	 * @param map a name-value map of entries to encode
	 * @return a byte buffer which can be used in {@link EasyHttpClient#doPostUrlEncoded}
	 * Strings are converted to the default platform encoding.
	 */
	public static byte[] urlEncode(Map<String, String> map) {
		try {
			return urlEncode(map, null);
		} catch (UnsupportedEncodingException e) {
			throw new Error("should not be here");
		}
	}

	/**
	 * URL decodes a string.
	 */
	@SuppressWarnings("deprecation")
	private static String decode(String s) {
		return URLDecoder.decode(s);
	}

	/**
	 * Returns the number of times a character appears in a string.
	 */
	private static int count(String s, char c) {
		int count = 0;
		for (int i = 0; i < s.length(); i++)
			if (s.charAt(i) == c)
				count++;
		return count;
	}
	
	/**
	 * Creates and returns a new array one entry longer, with a new value at the end.
	 * @param sa existing array
	 * @param s new value
	 * @return new array
	 */
	public static String[] add(String sa[], String s) {
		String sa2[] = new String[sa.length + 1];
		System.arraycopy(sa, 0, sa2, 0, sa.length);
		sa2[sa.length] = s;
		return sa2;
		
	}
	
	private static void put(Map<String, String[]> map, String n, String v) {
		String[] sa = map.get(n);
		if (sa == null)
			map.put(n, new String[] { v });
		else
			map.put(n, add(sa, v));
	}
	
	/**
	 * URL decodes a string into a <code>Map</code>.
	 * Names without values are placed in the map, but given a
	 * <code>null</code> value.  For example:
	 * <pre>
	 * foo&amp;bar=&amp;baz
	 * </pre>
	 * results in a map with keys <code>foo</code>, <code>bar</code>,
	 * <code>baz</code> mapped to <code>""</code>.
	 * If the same key appears more than once, it is added to the array.
	 * This method will not throw exceptions
	 * even if the data is irregular.
	 *
	 * @param urlEncodedData data in the URL encoded format
	 * @return a map containing the decoded name-value pairs
	 * @throws IllegalArgumentException if null data is passed in
	 * @see #urlEncode
	 */
	public static Map<String, String[]> urlDecode(String urlEncodedData) {
		if (urlEncodedData == null)
			throw new IllegalArgumentException("Null urlEncodedData");
		urlEncodedData = urlEncodedData.trim();
		StringTokenizer st = new StringTokenizer(urlEncodedData, "&=", true);
		Map<String, String[]> map = new HashMap<String, String[]>();
		String n;
		String eq;
		String v;
		while (st.hasMoreTokens()) {
			n = st.nextToken();
			if (!st.hasMoreTokens()) {
    			put(map, decode(n), "");
				break;
			}
			eq = st.nextToken();
			if (!st.hasMoreTokens()) {
    			put(map, decode(n), "");
				break;
			}
			if (eq.equals("&")) {
    			put(map, decode(n), "");
				continue;
			}
			v = st.nextToken();
			if (!v.equals("&")) {
				String dn = decode(n);
				String dv = decode(v);
				put(map, dn, dv);
			} else {
    			put(map, decode(n), "");
				continue;
			}
			if (st.hasMoreTokens())
				st.nextToken(); // &
		}
		return map;
	}

	/**
	 * URL decodes an input stream.
	 * @return mapping of name value pairs
	 */
	public static Map<String, String[]> urlDecode(InputStream is) throws IOException {
		byte[] bs = HttpUtil.read(is);
		return urlDecode(new String(bs, "ASCII"));
	}

	/**
	 * URL decodes an input stream.
	 * @return array of name value pairs
	 */
	public static String[] urlDecodeToArray(InputStream is) throws IOException {
		byte[] bs = HttpUtil.read(is);
		return urlDecodeToArray(new String(bs, "ASCII"));
	}
	
	/**
	 * Performs the same operation as {@link #urlDecode}, except the
	 * returned data is in an ordered array.
	 *
	 * @see #urlEncode
	 */
	public static String[] urlDecodeToArray(String urlEncodedData) {
		if (urlEncodedData == null)
			throw new IllegalArgumentException("Null urlEncodedData");
		urlEncodedData = urlEncodedData.trim();
		StringTokenizer st = new StringTokenizer(urlEncodedData, "&=", true);
		int amps = count(urlEncodedData, '&');
		String array[] = new String[(amps + 1) * 2];
		String n;
		String eq;
		String v;
		int i = -2;
		while (st.hasMoreTokens()) {
			i += 2;
			n = st.nextToken();
			array[i] = decode(n);
			if (!st.hasMoreTokens())
				break;
			eq = st.nextToken();
			if (!st.hasMoreTokens())
				break;
			if (eq.equals("&"))
				continue;
			v = st.nextToken();
			if (!v.equals("&"))
				array[i + 1] = decode(v);
			else
				continue;
			if (st.hasMoreTokens())
				st.nextToken(); // &
		}
		return array;
	}

	/**
	 * Discards the contents of a <code>BufferedReader</code>.
	 * If the reader is <code>null</code>, does nothing: 
	 * This is to facilitate the coding of cases when the data is of
	 * no use to the client.
	 */
	public static void discard(BufferedReader r)
		throws IOException
	{
		if (r == null)
			return;
		while (r.readLine() != null);
	}

	/**
	 * Returns the contents of a <code>BufferedReader</code> as a String.
	 * By default, each line is appended with
	 * <code>System.getProperty("line.separator")</code>.
	 * This may or may not be the original line termination character used.
	 *
	 * @throws IllegalArgumentException if the reader is null
	 */
	public static String read(BufferedReader r)
		throws IOException
	{
		if (r == null)
			throw new IllegalArgumentException("null reader");
		String sep = System.getProperty("line.separator", "\n");
		StringBuilder sb = new StringBuilder(128);
		String line;
		while ((line = r.readLine()) != null) {
			sb.append(line).append(sep);
		}
		return sb.toString();
	}

}
