package net.noderunner.amazon.s3;

import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.security.Key;
import java.security.NoSuchAlgorithmException;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.SortedMap;
import java.util.TreeMap;

import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

import org.apache.commons.codec.binary.Base64;
import org.apache.commons.httpclient.util.EncodingUtil;

/**
 * Creates canonical strings for authorization purposes by hashing
 * the request against an authorization key.
 * 
 * @author Elias Ross
 */
public class CanonicalString {

	private static final String AMAZON_HEADER_PREFIX = "x-amz-";
	private static final String ALTERNATIVE_DATE_HEADER = "x-amz-date";
	private static final Charset UTF8 = Charset.forName("UTF-8");
	
	/**
	 * HMAC/SHA1 Algorithm per RFC 2104.
	 */
	private static final String HMAC_SHA1_ALGORITHM = "HmacSHA1";

    private CanonicalString() {
    }
    
	/**
	 * Returns a canonical string used in authentication.
	 */
	public static String make(Method method, Bucket bucket, String key, Map<String, String> pathArgs, Headers headers) {
	    return make(method, bucket, key, pathArgs, headers, null);
	}

	/**
	 * Returns a canonical string used in authentication.
	 * 
	 * @param expires When non-null, it will be used instead of the Date header.
	 * @param key URL-encoded string
	 */
	public static String make(Method method, Bucket bucket, String key, Map<String, String> pathArgs, 
	                                         Headers headers, String expires)
	{
	    StringBuilder buf = new StringBuilder(128);
	    buf.append(method.name()).append("\n");
	
	    // Add all interesting headers to a list, then sort them.  "Interesting"
	    // is defined as Content-MD5, Content-Type, Date, and x-amz-
	    SortedMap<String, String> interestingHeaders = new TreeMap<String, String>();
	    if (headers != null) {
	        for (Map.Entry<String, List<String>> me : headers.getHeaders().entrySet()) {
	            String hashKey = me.getKey(); 
	            if (hashKey == null)
	            	continue;
	            String lk = hashKey.toLowerCase(Locale.US);
	
	            // Ignore any headers that are not particularly interesting.
	            if (lk.equals("content-type") || lk.equals("content-md5") || lk.equals("date") ||
	                lk.startsWith(AMAZON_HEADER_PREFIX))
	            {
	                interestingHeaders.put(lk, concatenateList(me.getValue()));
	            }
	        }
	    }
	
	    if (interestingHeaders.containsKey(ALTERNATIVE_DATE_HEADER)) {
	        interestingHeaders.put("date", "");
	    }
	
	    // if the expires is non-null, use that for the date field.  this
	    // trumps the x-amz-date behavior.
	    if (expires != null) {
	        interestingHeaders.put("date", expires);
	    }
	
	    // these headers require that we still put a new line in after them,
	    // even if they don't exist.
	    if (! interestingHeaders.containsKey("content-type")) {
	        interestingHeaders.put("content-type", "");
	    }
	    if (! interestingHeaders.containsKey("content-md5")) {
	        interestingHeaders.put("content-md5", "");
	    }
	
	    // Finally, add all the interesting headers (i.e.: all that startwith x-amz- ;-))
	    for (Map.Entry<String, String> me : interestingHeaders.entrySet()) {
	        String headerKey = me.getKey();
	        if (headerKey.startsWith(AMAZON_HEADER_PREFIX)) {
	            buf.append(headerKey).append(':').append(me.getValue());
	        } else {
	            buf.append(me.getValue());
	        }
	        buf.append("\n");
	    }
	    
	    // build the path using the bucket and key
	    if (bucket != null && bucket.specified()) {
	        buf.append("/" + bucket.getName() );
	    }
	    
	    // append the key (it might be an empty string)
	    // append a slash regardless
	    buf.append("/");
	    if (key != null) {
	        buf.append(key);
	    }
	    
	    // if there is an acl, logging or torrent parameter
	    // add them to the string
	    if (pathArgs != null ) {
	        if (pathArgs.containsKey("acl")) {
	            buf.append("?acl");
	        } else if (pathArgs.containsKey("torrent")) {
	            buf.append("?torrent");
	        } else if (pathArgs.containsKey("logging")) {
	            buf.append("?logging");
	            } else if (pathArgs.containsKey("location")) {
	                buf.append("?location");
	            }
	    }
	
	    return buf.toString();
	}

	/**
	 * Concatenates a bunch of header values, separating them with a comma.
	 * @param values List of header values.
	 * @return String of all headers, with commas.
	 */
	private static String concatenateList(List<String> values) {
	    StringBuilder buf = new StringBuilder();
	    for (int i = 0, size = values.size(); i < size; ++ i) {
	        buf.append(values.get(i).replaceAll("\n", "").trim());
	        if (i != (size - 1)) {
	            buf.append(",");
	        }
	    }
	    return buf.toString();
	}
	
	/**
	 * Returns an encrypted key for the access key.
	 */
	static Key key(String awsSecretAccessKey) {
	    // The following HMAC/SHA1 code for the signature is taken from the
	    // AWS Platform's implementation of RFC2104 (amazon.webservices.common.Signature)
	    //
	    // Acquire an HMAC/SHA1 from the raw key bytes.
	    SecretKeySpec signingKey =
	        new SecretKeySpec(awsSecretAccessKey.getBytes(), HMAC_SHA1_ALGORITHM);
	    return signingKey;
	}
	
	
	/**
	 * Calculate the HMAC/SHA1 on a string.
	 * @return Signature
	 */
	static String encode(Key signingKey, String canonicalString)
	{
	    Mac mac;
	    try {
	        mac = Mac.getInstance(HMAC_SHA1_ALGORITHM);
	    } catch (NoSuchAlgorithmException e) {
	        throw new RuntimeException("Could not find sha1 algorithm", e);
	    }
	    try {
	        mac.init(signingKey);
	    } catch (InvalidKeyException e) {
	        throw new RuntimeException("Could not initialize the MAC algorithm", e);
	    }
	
		mac.update(UTF8.encode(canonicalString));
    	byte[] encode = Base64.encodeBase64(mac.doFinal());
    	return EncodingUtil.getAsciiString(encode);
	}

}
