package org.limewire.util;

import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.util.List;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;


/**
 * Convert Java objects into bencoded data.
 * 
 * Call BEncoder.encode(OutputStream, Object) to bencode a given Object and write the bencoded data to the given OutputStream.
 * 
 * Bencoded data is composed of strings, numbers, lists, and dictionaries.
 * Strings are prefixed by their length, like "5:hello".
 * Numbers are written as text numerals between the letters "i" and "e", like "i87e".
 * You can list any number of bencoded pieces of data between "l" for list and "e" for end.
 * A dictionary is a list of key and value pairs between "d" and "e".
 * The keys have to be strings, and they have to be in alphabetical order.
 * 
 * BitTorrent uses a simple and extensible data format called bencoding.
 * More information on bencoding is on the Web at:
 * http://en.wikipedia.org/wiki/Bencoding
 * http://www.bittorrent.org/protocol.html in the section titled "The connectivity is as follows".
 */
public class BEncoder {

    /** Identifies a bencoded real number. */
    public static final byte I;
    /** Identifies a bencoded rational number. */
    public static final byte R;
    /** Identifies a bencoded dictionary. */
    public static final byte D;
    /** Identifies a bencoded list. */
    public static final byte L;
    /** Marks the end of something in bencoding. */
    public static final byte E;
    
    /** Markers for true and false tokens */
    public static final byte TRUE;
    public static final byte FALSE;
    
    /** Separates the length from the string in the data of a bencoded string. */
    public final static byte COLON;
    
    private static final String ASCII = "ISO-8859-1";
    
    static {
        byte i = 0;
        byte d = 0;
        byte l = 0;
        byte e = 0;
        byte colon = 0;
        byte t = 0;
        byte f = 0;
        byte r = 0;

        try {

            i = "i".getBytes(ASCII)[0];
            d = "d".getBytes(ASCII)[0];
            l = "l".getBytes(ASCII)[0];
            e = "e".getBytes(ASCII)[0];
            colon = ":".getBytes(ASCII)[0];
            t = "t".getBytes(ASCII)[0];
            f = "f".getBytes(ASCII)[0];
            r = "r".getBytes(ASCII)[0];

        } catch (UnsupportedEncodingException impossible) {

            // TODO: connect to the error service
        }

        COLON = colon;
        I = i;
        D = d;
        L = l;
        E = e;
        TRUE = t;
        FALSE = f;
        R = r;
    }
    
    private final boolean fail, bool;
    private final String encoding;
    private final OutputStream output;
    private BEncoder(OutputStream output, boolean fail, boolean bool, String encoding) {
        this.fail = fail;
        this.encoding = encoding;
        this.output = output;
        this.bool = bool;
    }

    public static BEncoder getEncoder(OutputStream out) {
        return new BEncoder(out, false, true, ASCII);
    }
    
    public static BEncoder getEncoder(OutputStream out, boolean fail, boolean bool, String encoding) {
        return new BEncoder(out, fail, bool, encoding);
    }
    
    /**
     * Bencodes the given byte array to the given OutputStream.
     * 
     * Writes the length, a colon, and then the text.
     * For example, the byte array ['h', 'e', 'l', 'l', 'o'] becomes the bencoded bytes "5:hello".
     * 
     * @param output An OutputStream for this method to write bencoded data to
     * @param b      The byte array to bencode and write
     */
    public void encodeByteArray(byte[] b) throws IOException {
        String length = String.valueOf(b.length);
        output.write(length.getBytes(ASCII));
        output.write(COLON);
        output.write(b);
    }

    /**
     * Bencodes the given Number to the given OutputStream.
     * 
     * Writes the base 10 digits of the number between the letters "i" and "e".
     * For example, the number 87 becomes the bencoded ASCII bytes "i87e".
     * 
     * @param output An OutputStream for this method to write bencoded data to
     * @param n      The number to bencode and write
     */
    public void encodeInt(Number n) throws IOException {
        String numerals = String.valueOf(n.longValue());
        output.write(I);
        output.write(numerals.getBytes(ASCII));
        output.write(E);
    }
    

    /**
     * Bencodes the given Rational Number to the given OutputStream.
     * 
     * Writes the base 10 digits of the number's internal memory representation
     * between the letters "r" and "e".
     * 
     * @param output An OutputStream for this method to write bencoded data to
     * @param n      The number to bencode and write
     */
    public void encodeRational(Number n) throws IOException {
        String numerals = String.valueOf(Double.doubleToLongBits(n.doubleValue()));
        output.write(R);
        output.write(numerals.getBytes(ASCII));
        output.write(E);
    }

    /**
     * Bencodes the given Iterable to the given OutputStream.
     * 
     * Writes "l" for list, the bencoded-form of each of the given objects, and then "e" for end.
     * 
     * @param output An OutputStream for this method to write bencoded data to
     * @param iterable   A Java Iterable object to bencode and write
     * @param encoding the encoding to apply to string objects.
     */
    public void encodeList(Iterable<?> iterable) throws IOException {
        output.write(L);
        for(Object next : iterable) 
            encode(next);
        output.write(E);
    }

    /**
     * Bencodes the given Map to the given OutputStream.  Any String objects
     * are encoded using ASCII.
     * 
     * Writes a bencoded dictionary, which is a list of keys and values which looks like this:
     * 
     * d
     * 5:color  5:green
     * 6:flavor 4:lime
     * 5:shape  5:round
     * e
     * 
     * The bencoded data starts "d" for dictionary and ends "e" for end.
     * In the middle are pairs of bencoded values.
     * The keys have to be strings, while the values can be strings, numbers, lists, or more dictionaries.
     * The keys have to be in alphabetical order.
     * 
     * @param o   An OutputStream for this method to write bencoded data to
     * @param map The Java Map object to bencode and write
     */
    public void encodeDict(Map<?, ?> map) throws IOException {

        // The BitTorrent specification requires that dictionary keys are sorted in alphanumeric order
        SortedMap<String, Object> sorted = new TreeMap<String, Object>();
        for(Map.Entry<?, ?> entry : map.entrySet())
            sorted.put(entry.getKey().toString(), entry.getValue());

        output.write(D);
        for(Map.Entry<String, Object> entry : sorted.entrySet()) {
            if (isValidType(entry.getKey()) && isValidType(entry.getValue())) {
                encodeByteArray(entry.getKey().getBytes(encoding));
                encode(entry.getValue());
            } else if (fail)
                throw new IllegalArgumentException();
        }
        output.write(E);
    }

    /**
     * Describes a given object using bencoding, and writes the bencoded data to the given stream.
     * 
     * To write a bencoded dictionary, pass a Map object.
     * To write a bencoded list, pass a List object.
     * To write a bencoded number, pass a Number object.
     * To write a bencoded string, pass a String or just a byte array.
     * 
     * @param  output                   An OutputStream for this method to write bencoded data to.
     * @param  object                   The Java Object to bencode and write.
     * @throws IOException              If there was a problem reading from the OutputStream.
     *         IllegalArgumentException If you pass an object that isn't a Map, List, Number, String, or byte array.
     */
    private void encode(Object object) throws IOException {
    	if (object instanceof Map)
    		encodeDict((Map)object);
    	else if (object instanceof Iterable<?>)
    		encodeList((Iterable<?>)object);
    	else if (object instanceof Number) {
    	    if (object instanceof Double || object instanceof Float)
    	        encodeRational((Number)object);
    	    else
    	        encodeInt((Number)object);
    	}
    	else if (object instanceof String)
    		encodeByteArray(((String)object).getBytes(encoding));
    	else if (object instanceof byte[])
    		encodeByteArray((byte[])object);
        else if (object instanceof Boolean) 
            encodeBoolean(((Boolean)object).booleanValue());
        else if (fail)
    		throw new IllegalArgumentException();
    }
    
    public void encodeBoolean(boolean value) throws IOException {
        if (bool)
            output.write(value ? TRUE : FALSE);
        else 
            encodeInt(value ? 1 : 0);
    }
    
    private static boolean isValidType(Object o ) {
        return (o instanceof Map) ||
        (o instanceof List )||
        (o instanceof Number) ||
        (o instanceof String) ||
        (o instanceof byte[]) ||
        (o instanceof Boolean);
    }
}
