//  This software code is made available "AS IS" without warranties of any
//  kind.  You may copy, display, modify and redistribute the software
//  code either by itself or as incorporated into your code; provided that
//  you do not remove any proprietary notices.  Your use of this software
//  code is at your own risk and you waive any claim against Amazon
//  Digital Services, Inc. or its affiliates with respect to your use of
//  this software code. (c) 2006-2007 Amazon Digital Services, Inc. or its
//  affiliates.

package net.noderunner.amazon.s3;

import java.io.IOException;
import java.security.Key;
import java.text.SimpleDateFormat;
import java.util.Collections;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TimeZone;

import org.apache.commons.httpclient.HostConfiguration;
import org.apache.commons.httpclient.HttpClient;
import org.apache.commons.httpclient.HttpMethod;
import org.apache.commons.httpclient.MultiThreadedHttpConnectionManager;
import org.apache.commons.httpclient.URI;
import org.apache.commons.httpclient.methods.ByteArrayRequestEntity;
import org.apache.commons.httpclient.methods.GetMethod;
import org.apache.commons.httpclient.methods.PutMethod;
import org.apache.commons.httpclient.methods.StringRequestEntity;
import org.apache.commons.httpclient.params.HttpMethodParams;

/**
 * A stateless connection to the Amazon S3 system which uses the REST API.
 * <p/>
 * It is initially configured with authentication and connection parameters and
 * exposes methods to access and manipulate S3 data.
 */
public class Connection {
	
    /**
     * Location default.
     */
    public static final String LOCATION_DEFAULT = null;
    
    /**
     * Location in Europe.
     */
    public static final String LOCATION_EU = "EU";
    
    /**
     * Default hostname.
     */
    public static final String DEFAULT_HOST = "s3.amazonaws.com";
    
    /**
     * HTTP port.
     */
	public static final int INSECURE_PORT = 80;
	
	/**
	 * HTTPS port.
	 */
	public static final int SECURE_PORT = 443;

	/**
	 * Data larger than 1024 bytes will use expect headers.
	 * See: http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html 14.20
	 */
	public static final int EXPECT_SIZE = 1024;
	
    private String awsAccessKeyId;
    private Key awsSecretAccessKey;
    private boolean isSecure;
    private String server;
    private int port;
	private CallingFormat callingFormat;
	
	private MultiThreadedHttpConnectionManager connectionManager = 
		new MultiThreadedHttpConnectionManager();
	private HostConfiguration config;
	private HttpClient client;
	
	static {
    	String charset = URI.getDefaultProtocolCharset();
    	if (!charset.equals("UTF-8"))
    		throw new Error("URI charset must be UTF-8: " + charset);
	}
	
	/**
	 * Constructs a new Connection.
	 */
	public Connection(String awsAccessKeyId, String awsSecretAccessKey) {
        this(awsAccessKeyId, awsSecretAccessKey, true);
    }

	/**
	 * Constructs a new Connection.
	 */
    public Connection(String awsAccessKeyId, String awsSecretAccessKey, boolean isSecure) {
        this(awsAccessKeyId, awsSecretAccessKey, isSecure, DEFAULT_HOST);
    }
    
	/**
	 * Constructs a new Connection.
	 */
    public Connection(String awsAccessKeyId, String awsSecretAccessKey, boolean isSecure,
                             String server)
    {
        this(awsAccessKeyId, awsSecretAccessKey, isSecure, server,
             isSecure ? SECURE_PORT : INSECURE_PORT);
    }
    
	/**
	 * Constructs a new Connection.
	 */
    public Connection(String awsAccessKeyId, String awsSecretAccessKey, boolean isSecure, 
                             String server, int port) {
        this(awsAccessKeyId, awsSecretAccessKey, isSecure, server, port, CallingFormat.SUBDOMAIN);
        
    }

	/**
	 * Constructs a new Connection.
	 */
    public Connection(String awsAccessKeyId, String awsSecretAccessKey, boolean isSecure, 
                             String server, CallingFormat format) {
        this(awsAccessKeyId, awsSecretAccessKey, isSecure, server, 
             isSecure ? SECURE_PORT : INSECURE_PORT, 
             format);
    }

    /**
     * Create a new interface to interact with S3 with the given credential and connection
     * parameters
     *
     * @param awsAccessKeyId Your user key into AWS
     * @param awsSecretAccessKey The secret string used to generate signatures for authentication.
     * @param isSecure use SSL encryption
     * @param server Which host to connect to.  Usually, this will be s3.amazonaws.com
     * @param port Which port to use.
     * @param callingFormat Type of request Regular/Vanity or Pure Vanity domain
     */
    public Connection(String awsAccessKeyId, String awsSecretAccessKey, boolean isSecure,
                             String server, int port, CallingFormat format)
    {
        this.awsAccessKeyId = awsAccessKeyId;
        this.awsSecretAccessKey = CanonicalString.key(awsSecretAccessKey);
        this.isSecure = isSecure;
        this.server = server;
        this.port = port;
        this.callingFormat = format;
        
        config = new HostConfiguration();
        config.setHost(server, port, isSecure ? "http" : "https");
        client = new HttpClient(connectionManager);
        client.setHostConfiguration(config);
    }
    
    /**
     * Creates a new bucket.
     * @param bucket The name of the bucket to create.
     * @param location Desired location ("EU") (or null for default).
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     * @param metadata A Map of String to List of Strings representing the s3
     * metadata for this bucket (can be null).
     * @throws IllegalArgumentException on invalid location
     */
    public Response create(Bucket bucket, String location, Headers headers)
        throws IOException
    {
        String body;
        if (location == null) {
            body = null;
        } else if (LOCATION_EU.equals(location)) {
            if (!callingFormat.supportsLocatedBuckets())
                throw new IllegalArgumentException("Creating location-constrained bucket with unsupported calling-format");
            body = "<CreateBucketConstraint><LocationConstraint>" + location + "</LocationConstraint></CreateBucketConstraint>";
        } else
            throw new IllegalArgumentException("Invalid Location: "+location);

        // validate bucket name
        if (!bucket.validateName(callingFormat))
            throw new IllegalArgumentException("Invalid Bucket Name: "+bucket);

        PutMethod method = (PutMethod) makeRequest(Method.PUT, bucket, headers);
        if (body != null)
        {
        	StringRequestEntity sre = new StringRequestEntity(body, "text/xml", "UTF-8");
            method.setRequestEntity(sre);
        }
        executeRelease(method);
        return new Response(method);
    }
    
    private int execute(HttpMethod method) throws IOException {
    	return client.executeMethod(method);
    }
    
    private int executeRelease(HttpMethod method) throws IOException {
    	try {
        	return client.executeMethod(method);
    	} finally {
    		method.releaseConnection();
    	}
    }

	/**
     * Creates a new bucket with a location.
     */
    public Response create(Bucket bucket, String location) throws IOException {
    	return create(bucket, location, null);
    }
    
	/**
     * Creates a new bucket.
     */
	public Response create(Bucket bucket) throws IOException {
    	return create(bucket, null);
	}

    /**
     * Check if the specified bucket exists (via a HEAD request)
     * @param bucket The name of the bucket to check
     * @return true if HEAD access returned success
     * @see #head(Bucket, String)
     */
    public boolean exists(Bucket bucket) throws IOException
    {
        HttpMethod method = makeRequest(Method.HEAD, bucket);
        int httpCode = executeRelease(method);
        return httpCode >= 200 && httpCode < 300;
    }

	/**
     * Lists the contents of a bucket.
     * @param bucket The name of the bucket
     * @param prefix All returned keys will start with this string (can be null).
     * @param marker All returned keys will be lexographically greater than
     * this string (can be null).
     * @param maxKeys The maximum number of keys to return (can be null).
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public ListResponse list(Bucket bucket, String prefix, String marker,
                                         Integer maxKeys, Headers headers)
        throws IOException
    {
        return list(bucket, prefix, marker, maxKeys, null, headers);
    }

    /**
     * Lists the contents of a bucket.
     */
    public ListResponse list(Bucket bucket, String prefix, String marker,
                                         Integer maxKeys) throws IOException
    {
    	return list(bucket, prefix, marker, maxKeys, null);
    }
    
    /**
     * Lists the contents of a bucket.
     */
    public ListResponse list(Bucket bucket, Integer maxKeys) throws IOException
    {
    	return list(bucket, null, null, maxKeys);
    }
    
    /**
     * Lists the contents of a bucket.
     */
    public ListResponse list(Bucket bucket) throws IOException
    {
    	return list(bucket, null, null, null, null);
    }
    
    /**
     * Lists the contents of a bucket by prefix.
     */
	public ListResponse list(Bucket bucket, String prefix) throws IOException {
		return list(bucket, prefix, null, null);
	}
    
    /**
     * Lists the contents of a bucket.
     * @param bucket The name of the bucket to list.
     * @param prefix All returned keys will start with this string (can be null).
     * @param marker All returned keys will be lexographically greater than
     * this string (can be null).
     * @param maxKeys The maximum number of keys to return (can be null).
     * @param delimiter Keys that contain a string between the prefix and the first 
     * occurrence of the delimiter will be rolled up into a single element.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public ListResponse list(Bucket bucket, String prefix, String marker,
                                         Integer maxKeys, String delimiter, Headers headers)
        throws IOException
    {

        Map<String, String> pathArgs = paramsForListOptions(prefix, marker, maxKeys, delimiter);
		HttpMethod method = makeRequest(Method.GET, bucket, pathArgs, headers);
		try {
    		execute(method);
            return new ListResponse(method);
		} finally {
			method.releaseConnection();
		}
    }

	private static Map<String, String> paramsForListOptions(String prefix,
			String marker, Integer maxKeys, String delimiter)
	{
		Map<String, String> argParams = new HashMap<String, String>();
		if (prefix != null)
			argParams.put("prefix", prefix);
		if (marker != null)
			argParams.put("marker", marker);
		if (delimiter != null)
			argParams.put("delimiter", delimiter);

		if (maxKeys != null)
			argParams.put("max-keys", Integer.toString(maxKeys.intValue()));

		return argParams;

	}

	/**
     * Deletes a bucket.
     * @param bucket The name of the bucket to delete.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public Response delete(Bucket bucket, Headers headers)
        throws IOException
    {
		HttpMethod method = makeRequest(Method.DELETE, bucket, "", null, headers);
		executeRelease(method);
        return new Response(method);
    }

    /**
     * Deletes a bucket.
     */
    public Response delete(Bucket bucket)
        throws IOException
    {
    	return delete(bucket, (Headers)null);
    }
    
    /**
     * Writes an object to S3.
     * @param bucket The name of the bucket to which the object will be added.
     * @param key The name of the key to use.
     * @param object An S3Object containing the data to write.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public Response put(Bucket bucket, String key, S3Object object, Headers headers)
        throws IOException
    {
        PutMethod request = (PutMethod) makeRequest(Method.PUT, bucket, key, null, headers, object);
        return execute(request, object);
    }
    
    /**
     * Writes an object to S3.
     */
    public Response put(Bucket bucket, String key, S3Object object) throws IOException {
    	return put(bucket, key, object, null);
    }

    /**
     * Reads an object from S3.
     * @param bucket The name of the bucket where the object lives.
     * @param key The name of the key to use.
     * @param headers HTTP headers to pass (can be null).
     */
    public GetResponse get(Bucket bucket, String key, Headers headers)
        throws IOException
    {
		HttpMethod method = makeRequest(Method.GET, bucket, key, null, headers);
		try {
    		execute(method);
            return new GetResponse(method);
		} finally {
			method.releaseConnection();
		}
    }
    
    /**
     * Reads an object from S3.
     */
    public GetResponse get(Bucket bucket, String key) throws IOException
    {
    	return get(bucket, key, null);
    }
    
    /**
     * Reads an object from S3, returning a stream to access the data.
     * This is preferable when dealing with large objects.
     * 
     * @param bucket The name of the bucket where the object lives.
     * @param key The name of the key to use.
     * @param headers HTTP headers to pass (can be null).
     */
    public GetStreamResponse getStream(Bucket bucket, String key, Headers headers)
        throws IOException
    {
		HttpMethod method = makeRequest(Method.GET, bucket, key, null, headers);
		boolean ok = false;
		try {
    		execute(method);
    		ok = true;
            return new GetStreamResponse((GetMethod)method);
		} finally {
			if (!ok)
    			method.releaseConnection();
		}
    }
    
    /**
     * Reads an object from S3, returning a stream to access the data.
     * This is preferable when dealing with large objects.
     */
    public GetStreamResponse getStream(Bucket bucket, String key)
        throws IOException
    {
    	return getStream(bucket, key, null);
    }
    
    /**
     * Returns information about an S3 object without loading it.
     * Check {@link Response#isOk()} or {@link Response#isNotFound()} to see if the object exists.
     */
    public Response head(Bucket bucket, String key) throws IOException {
    	return head(bucket, key, null);
    }
    
    /**
     * Returns information about an S3 object without loading it.
     */
    public Response head(Bucket bucket, String key, Headers headers) throws IOException {
		HttpMethod method = makeRequest(Method.HEAD, bucket, key, null, headers);
		executeRelease(method);
        return new Response(method);
    }

    /**
     * Deletes an object from S3.
     * @param bucket The name of the bucket where the object lives.
     * @param key The name of the key to use.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public Response delete(Bucket bucket, String key, Headers headers)
        throws IOException
    {
		HttpMethod method = makeRequest(Method.DELETE, bucket, key, null, headers);
		executeRelease(method);
        return new Response(method);
    }
    
    /**
     * Deletes an object from S3.
     */
    public Response delete(Bucket bucket, String key) throws IOException
    {
    	return delete(bucket, key, null);
    }

    /**
     * Get the logging xml document for a given bucket
     * @param bucket The name of the bucket
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public GetResponse getBucketLogging(Bucket bucket, Headers headers)
        throws IOException
    {
        Map<String, String> pathArgs = Collections.singletonMap("logging", "");
		HttpMethod method = makeRequest(Method.GET, bucket, "", pathArgs, headers);
		try {
    		execute(method);
            return new GetResponse(method);
		} finally {
			method.releaseConnection();
		}
    }

    /**
     * Write a new logging xml document for a given bucket
     * @param loggingXMLDoc The xml representation of the logging configuration as a String
     * @param bucket The name of the bucket
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public Response putBucketLogging(Bucket bucket, String loggingXMLDoc, Headers headers)
        throws IOException
    {
        Map<String, String> pathArgs = Collections.singletonMap("logging", "");
        S3Object object = new S3Object(loggingXMLDoc.getBytes(), null);
        PutMethod request = (PutMethod) makeRequest(Method.PUT, bucket, "", pathArgs, headers, object);
        return execute(request, object);
    }
    
    private Response execute(PutMethod request, S3Object object) throws IOException {
    	if (object.getLength() > EXPECT_SIZE)
    		request.getParams().setBooleanParameter(HttpMethodParams.USE_EXPECT_CONTINUE, true);
        // request.setContentChunked(true);
        request.setRequestEntity(new ByteArrayRequestEntity(object.getData()));
        executeRelease(request);
        return new Response(request);
    }

    /**
     * Get the ACL for a given bucket
     * @param bucket The name of the bucket where the object lives.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public GetResponse getACL(Bucket bucket, Headers headers)
        throws IOException
    {
        return getACL(bucket, "", headers);
    }

    /**
     * Get the ACL for a given object (or bucket, if key is null).
     * @param bucket The name of the bucket where the object lives.
     * @param key The name of the key to use.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public GetResponse getACL(Bucket bucket, String key, Headers headers)
        throws IOException
    {
        if (key == null)
        	key = "";
        
        Map<String, String> pathArgs = Collections.singletonMap("acl", "");
        HttpMethod method = makeRequest(Method.GET, bucket, key, pathArgs, headers);
        try {
            execute(method);
            return new GetResponse(method);
        } finally {
        	method.releaseConnection();
        }
    }

    /**
     * Write a new ACL for a given bucket
     * @param aclXMLDoc The xml representation of the ACL as a String
     * @param bucket The name of the bucket where the object lives.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public Response putACL(Bucket bucket, String aclXMLDoc, Headers headers)
        throws IOException
    {
        return putACL(bucket, "", aclXMLDoc, headers);
    }

    /**
     * Write a new ACL for a given object
     * @param aclXMLDoc The xml representation of the ACL as a String
     * @param bucket The name of the bucket where the object lives.
     * @param key The name of the key to use.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    public Response putACL(Bucket bucket, String key, String aclXMLDoc, Headers headers)
        throws IOException
    {
        S3Object object = new S3Object(aclXMLDoc);
        Map<String, String> pathArgs = Collections.singletonMap("acl", "");
        PutMethod request =
            (PutMethod) makeRequest(Method.PUT, bucket, key, pathArgs, headers, object);
        return execute(request, object);

    }

    /**
     * Returns the bucket location.
     */
    public LocationResponse getLocation(Bucket bucket) 
        throws IOException 
    {
        Map<String, String> pathArgs = Collections.singletonMap("location", "");
		HttpMethod method = makeRequest(Method.GET, bucket, "", pathArgs, null);
		try {
    		execute(method);
            return new LocationResponse(method);
		} finally {
			method.releaseConnection();
		}
    }
        
    
    /**
     * Lists all the buckets created by this account.
     */
    public ListAllBucketsResponse listAllBuckets(Headers headers)
        throws IOException
    {
		HttpMethod method = makeRequest(Method.GET, null, "", null, headers);
		try {
    		execute(method);
            return new ListAllBucketsResponse(method);
		} finally {
			method.releaseConnection();
		}
    }
    
    /**
     * Lists all the buckets created by this account.
     */
    public ListAllBucketsResponse listAllBuckets() throws IOException
    {
    	return listAllBuckets(null);
    }
    
    /**
     * Make a new HttpMethod without passing an S3Object parameter. 
     * Use this method for key operations that do require arguments
     * @param method The method to invoke
     * @param bucketName the bucket this request is for
     * @param key the key this request is for
     * @param pathArgs the 
     * @param headers
     * @return
     * @throws IOException
     */
    private HttpMethod makeRequest(Method method, Bucket bucket, String key, Map<String, String> pathArgs, Headers headers)
        throws IOException
    {
        return makeRequest(method, bucket, key, pathArgs, headers, null);
    }

    private HttpMethod makeRequest(Method method, Bucket bucket) throws IOException {
		return makeRequest(method, bucket, null);
	}

    private HttpMethod makeRequest(Method method, Bucket bucket, Headers headers) throws IOException {
    	return makeRequest(method, bucket, null, headers);
	}

    private HttpMethod makeRequest(Method method, Bucket bucket, Map<String, String> pathArgs, Headers headers) throws IOException
    {
    	return makeRequest(method, bucket, "", pathArgs, headers);
	}

    /**
     * Make a new HttpMethod.
     * @param method The HTTP method to use (GET, PUT, DELETE)
     * @param bucketNamePattern The bucket name this request affects
     * @param key The key this request is for, not encoded
     * @param pathArgs parameters if any to be sent along this request
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     * @param object The S3Object that is to be written (can be null).
     */
    private HttpMethod makeRequest(Method method, Bucket bucket, String key, Map<String, String> pathArgs, Headers headers,
                                          S3Object object)
        throws IOException
    {
    	HttpMethod httpMethod = method.createHttpMethod();
    	
        URI uri = this.callingFormat.getURI(this.isSecure, server, this.port, bucket, key, pathArgs);
    	
    	httpMethod.setURI(uri);
        addHeaders(httpMethod, headers);
        if (object != null)
        	addMetadataHeaders(httpMethod, object.getMetadata());
        addAuthHeader(httpMethod, method, bucket, key, pathArgs);
        return httpMethod;
    }

    /**
     * Add the given headers to the HttpMethod.
     * @param httpMethod The HttpMethod to which the headers will be added.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     */
    private void addHeaders(HttpMethod httpMethod, Headers headers) {
        addHeaders(httpMethod, headers, "");
    }

    /**
     * Add the given metadata fields to the HttpMethod.
     * @param httpMethod The HttpMethod to which the headers will be added.
     * @param metadata A Map of String to List of Strings representing the s3
     * metadata for this resource.
     */
    private void addMetadataHeaders(HttpMethod httpMethod, Headers metadata) {
        addHeaders(httpMethod, metadata, Headers.METADATA_PREFIX);
    }

    /**
     * Add the given headers to the HttpMethod with a prefix before the keys.
     * @param httpMethod The HttpMethod to which the headers will be added.
     * @param headers A Map of String to List of Strings representing the http
     * headers to pass (can be null).
     * @param prefix The string to prepend to each key before adding it to the connection.
     */
    private void addHeaders(HttpMethod httpMethod, Headers headers, String prefix) {
        if (headers != null) {
        	for (Map.Entry<String, List<String>> me : headers.getHeaders().entrySet()) {
                String key = me.getKey();
                for (String value : me.getValue()) {
                    httpMethod.addRequestHeader(prefix + key, value);
                }
            }
        }
    }

    /**
     * Add the appropriate Authorization header to the HttpMethod.
     * @param httpMethod The HttpMethod to which the header will be added.
     * @param method The HTTP method to use (GET, PUT, DELETE)
     * @param bucket the bucket name this request is for
     * @param key the key this request is for (not URL encoded)
     * @param pathArgs path arguments which are part of this request
     */
    private void addAuthHeader(HttpMethod httpMethod, Method method, Bucket bucket, String key, Map<String, String> pathArgs) {
        if (httpMethod.getRequestHeader("Date") == null) {
            httpMethod.setRequestHeader("Date", httpDate());
        }
        if (httpMethod.getRequestHeader("Content-Type") == null) {
            httpMethod.setRequestHeader("Content-Type", "");
        }

        Headers prop = new Headers(httpMethod.getRequestHeaders());
        String enckey = UrlEncoder.encode(key);
        String canonicalString = CanonicalString.make(method, bucket, enckey, pathArgs, prop);
        String encodedCanonical = CanonicalString.encode(this.awsSecretAccessKey, canonicalString);
        httpMethod.setRequestHeader("Authorization",
                                      "AWS " + this.awsAccessKeyId + ":" + encodedCanonical);
    }

    /**
     * Generate an rfc822 date for use in the Date HTTP header.
     */
    private static String httpDate() {
        final String DateFormat = "EEE, dd MMM yyyy HH:mm:ss ";
        SimpleDateFormat format = new SimpleDateFormat( DateFormat, Locale.US );
        format.setTimeZone( TimeZone.getTimeZone( "UTC" ) );
        return format.format( new Date() ) + "GMT";
    }
    
    /**
     * Shuts down any managed or pooled connections.
     */
    public void shutdown() {
    	connectionManager.shutdown();
    }
    
    /**
     * Returns a debug string.
     */
    @Override
    public String toString() {
    	return "Connection id=" + awsAccessKeyId + 
    		" isSecure=" + isSecure + " server=" + server +
    		" port=" + port + " format=" + callingFormat;
    }

}
