package com.soundcloud.api;

import org.apache.http.HttpEntity;
import org.apache.http.NameValuePair;
import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
import org.apache.http.client.methods.HttpRequestBase;
import org.apache.http.entity.StringEntity;
import org.apache.http.entity.mime.HttpMultipartMode;
import org.apache.http.entity.mime.MIME;
import org.apache.http.entity.mime.MultipartEntity;
import org.apache.http.entity.mime.content.AbstractContentBody;
import org.apache.http.entity.mime.content.ContentBody;
import org.apache.http.entity.mime.content.FileBody;
import org.apache.http.entity.mime.content.StringBody;
import org.apache.http.message.BasicNameValuePair;
import org.apache.http.protocol.HTTP;

import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.io.UnsupportedEncodingException;
import java.net.URI;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.ByteBuffer;
import java.nio.charset.Charset;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Locale;
import java.util.Map;

/**
 * Convenience class for constructing HTTP requests.
 *
 * Example:
 * <code>
 *   <pre>
 *  HttpRequest request = Request.to("/tracks")
 *     .with("track[user]", 1234)
 *     .withFile("track[asset_data]", new File("track.mp3")
 *     .buildRequest(HttpPost.class);
 *
 *  httpClient.execute(request);
 *   </pre>
 *  </code>
 */
public class Request implements Iterable<NameValuePair> {
    public static final String UTF_8 = "UTF-8";

    private List<NameValuePair> mParams = new ArrayList<NameValuePair>(); // XXX should probably be lazy
    private Map<String, Attachment> mFiles;

    private HttpEntity mEntity;

    private Token mToken;
    private String mResource;
    private TransferProgressListener listener;
    private String mIfNoneMatch;
    private long[] mRange;

    /** Empty request */
    public Request() {}

    /**
     * @param resource the base resource
     */
    public Request(String resource) {
        if (resource == null) throw new IllegalArgumentException("resource is null");

        // make sure paths start with a slash
        if (!(resource.startsWith("http:") || resource.startsWith("https:"))
             && !resource.startsWith("/")) {
            resource = "/"+resource;
        }

        if (resource.contains("?")) {
            String query = resource.substring(Math.min(resource.length(), resource.indexOf("?")+1),
                    resource.length());
            for (String s : query.split("&")) {
                String[] kv = s.split("=", 2);
                if (kv != null) {
                    try {
                        if (kv.length == 2) {
                            mParams.add(new BasicNameValuePair(
                                    URLDecoder.decode(kv[0], UTF_8),
                                    URLDecoder.decode(kv[1], UTF_8)));
                        } else if (kv.length == 1) {
                            mParams.add(new BasicNameValuePair(URLDecoder.decode(kv[0], UTF_8), null));
                        }
                    } catch (UnsupportedEncodingException ignored) {
                    }
                }
            }
            mResource = resource.substring(0, resource.indexOf("?"));
        } else {
            mResource = resource;
        }
    }

    /**
     * constructs a a request from URI. the hostname+scheme will be ignored
     * @param uri - the uri
     */
    public Request(URI uri) {
        this(uri.getPath() == null ? "/" : uri.getPath() +
            (uri.getQuery() == null ? "" : "?"+uri.getQuery()));
    }

    /**
     * @param request the request to be copied
     */
    public Request(Request request) {
        mResource = request.mResource;
        mToken = request.mToken;
        listener = request.listener;
        mParams = new ArrayList<NameValuePair>(request.mParams);
        mIfNoneMatch = request.mIfNoneMatch;
        mEntity = request.mEntity;
        if (request.mFiles != null) mFiles = new HashMap<String, Attachment>(request.mFiles);
    }

    /**
     * @param resource  the resource to request
     * @param args      optional string expansion arguments (passed to String#format(String, Object...)
     * @throws java.util.IllegalFormatException - If a format string contains an illegal syntax,
     * @return the request
     * @see String#format(String, Object...)
     */
    public static Request to(String resource, Object... args) {
        if (args != null &&
            args.length > 0) {
            resource = String.format(Locale.ENGLISH, resource, args);
        }
        return new Request(resource);
    }

    /**
     * Adds a key value pair
     * @param name  the name
     * @param value the value
     * @return this
     */
    public Request add(String name, Object value) {
        mParams.add(new BasicNameValuePair(name, String.valueOf(value)));
        return this;
    }


    /**
     * Sets a new parameter, overwriting previous value.
     * @param name the name
     * @param value the value
     * @return this
     */
    public Request set(String name, Object value) {
        return clear(name).add(name, value);
    }

    /**
     * Clears a parameter
     * @param name name of the parameter
     * @return this
     */
    public Request clear(String name) {
        Iterator<NameValuePair> it = mParams.iterator();
        while (it.hasNext()) {
            if (it.next().getName().equals(name)) {
                it.remove();
            }
        }
        return this;
    }

    /**
     * @param args a list of arguments
     * @return this
     */
    public Request with(Object... args) {
       if (args != null) {
            if (args.length % 2 != 0) throw new IllegalArgumentException("need even number of arguments");
            for (int i = 0; i < args.length; i += 2) {
                add(args[i].toString(), args[i + 1]);
            }
       }
       return this;
    }

    /**
     * @param resource the new resource
     * @return a new request with identical parameters except for the specified resource.
     */
    public Request newResource(String resource) {
        Request newRequest = new Request(this);
        newRequest.mResource = resource;
        return newRequest;
    }

    /**
     * The request should be made with a specific token.
     * @param token the token
     * @return this
     */
    public Request usingToken(Token token) {
        mToken = token;
        return this;
    }

    /** @return the size of the parameters */
    public int size() {
        return mParams.size();
    }

    /**
     * @return a String that is suitable for use as an <code>application/x-www-form-urlencoded</code>
     * list of parameters in an HTTP PUT or HTTP POST.
     */
    public String queryString() {
        return format(mParams, UTF_8);
    }

    /**
     * @param  resource the resource
     * @return an URL with the query string parameters appended
     */
    public String toUrl(String resource) {
        return mParams.isEmpty() ? resource : resource + "?" + queryString();
    }

    public String toUrl() {
        return toUrl(mResource);
    }

    /**
     * Registers a file to be uploaded with a POST or PUT request.
     * @param name  the name of the parameter
     * @param file  the file to be submitted
     * @return this
     */
    public Request withFile(String name, File file) {
        return file != null ? withFile(name, file, file.getName()) : this;
    }

    /**
     * Registers a file to be uploaded with a POST or PUT request.
     * @param name  the name of the parameter
     * @param file  the file to be submitted
     * @param fileName  the name of the uploaded file (over rides file parameter)
     * @return this
     */
    public Request withFile(String name, File file, String fileName) {
        if (mFiles == null) mFiles = new HashMap<String,Attachment>();
        if (file != null)  mFiles.put(name, new Attachment(file, fileName));
        return this;
    }

    /**
     * Registers binary data to be uploaded with a POST or PUT request.
     * @param name  the name of the parameter
     * @param data  the data to be submitted
     * @deprecated use {@link #withFile(String, byte[], String)} instead
     * @return this
     */
    @Deprecated public Request withFile(String name, byte[] data) {
        return withFile(name, ByteBuffer.wrap(data));
    }

    /**
     * Registers binary data to be uploaded with a POST or PUT request.
     * @param name  the name of the parameter
     * @param data  the data to be submitted
     * @param fileName the name of the uploaded file
     * @return this
     */
    public Request withFile(String name, byte[] data, String fileName) {
        return withFile(name, ByteBuffer.wrap(data), fileName);
    }

    /**
     * Registers binary data to be uploaded with a POST or PUT request.
     * @param name  the name of the parameter
     * @param data  the data to be submitted
     * @return this
     * @deprecated use {@link #withFile(String, java.nio.ByteBuffer), String} instead
     */
    @Deprecated public Request withFile(String name, ByteBuffer data) {
        return withFile(name, data, "upload");
    }

    /**
     * Registers binary data to be uploaded with a POST or PUT request.
     * @param name  the name of the parameter
     * @param data  the data to be submitted
     * @param fileName the name of the uploaded file
     * @return this
     */
    public Request withFile(String name, ByteBuffer data, String fileName) {
        if (mFiles == null) mFiles = new HashMap<String, Attachment>();
        if (data != null) mFiles.put(name, new Attachment(data, fileName));
        return this;
    }

    /**
     * Adds an arbitrary entity to the request (used with POST/PUT)
     * @param entity the entity to POST/PUT
     * @return this
     */
    public Request withEntity(HttpEntity entity) {
        mEntity = entity;
        return this;
    }

    /**
     * Adds string content to the request (used with POST/PUT)
     * @param content the content to POST/PUT
     * @param contentType the content type
     * @return this
     */
    public Request withContent(String content, String contentType) {
        try {
            StringEntity stringEntity = new StringEntity(content, UTF_8);
            if (contentType != null) {
                stringEntity.setContentType(contentType);
            }
            return withEntity(stringEntity);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    public Request range(long... ranges) {
        mRange = ranges;
        return this;
    }

    /**
     * @param listener a listener for receiving notifications about transfer progress
     * @return this
     */
    public Request setProgressListener(TransferProgressListener listener) {
        this.listener = listener;
        return this;
    }

    public boolean isMultipart() {
        return mFiles != null && !mFiles.isEmpty();
    }

    /**
     * Conditional GET
     * @param etag the etag to check for (If-None-Match: etag)
     * @return this
     */
    public Request ifNoneMatch(String etag) {
        mIfNoneMatch = etag;
        return this;
    }

    public Map<String, String> getParams() {
        Map<String, String> params = new HashMap<String,String>();
        for (NameValuePair p : mParams) {
            params.put(p.getName(), p.getValue());
        }
        return params;
    }

    /**
     * Builds a request with the given set of parameters and files.
     * @param method    the type of request to use
     * @param <T>       the type of request to use
     * @return HTTP request, prepared to be executed
     */
    public <T extends HttpRequestBase> T buildRequest(Class<T> method) {
        try {
            T request = method.newInstance();
            // POST/PUT ?
            if (request instanceof HttpEntityEnclosingRequestBase) {
                HttpEntityEnclosingRequestBase enclosingRequest =
                        (HttpEntityEnclosingRequestBase) request;

                final Charset charSet = Charset.forName(UTF_8);
                if (isMultipart()) {
                    MultipartEntity multiPart = new MultipartEntity(
                            HttpMultipartMode.BROWSER_COMPATIBLE,  // XXX change this to STRICT once rack on server is upgraded
                            null,
                            charSet);

                    if (mFiles != null) {
                        for (Map.Entry<String, Attachment> e : mFiles.entrySet()) {
                            multiPart.addPart(e.getKey(), e.getValue().toContentBody());
                        }
                    }

                    for (NameValuePair pair : mParams) {
                        multiPart.addPart(pair.getName(), new StringBody(pair.getValue(), "text/plain", charSet));
                    }

                    enclosingRequest.setEntity(listener == null ? multiPart :
                        new CountingMultipartEntity(multiPart, listener));

                    request.setURI(URI.create(mResource));

                // form-urlencoded?
                } else if (mEntity != null) {
                    request.setHeader(mEntity.getContentType());
                    enclosingRequest.setEntity(mEntity);
                    request.setURI(URI.create(toUrl())); // include the params

                } else {
                    if (!mParams.isEmpty()) {
                        request.setHeader("Content-Type", "application/x-www-form-urlencoded");
                        enclosingRequest.setEntity(new StringEntity(queryString()));
                    }
                    request.setURI(URI.create(mResource));
                }

            } else { // just plain GET/HEAD/DELETE/...
                if (mRange != null) {
                    request.addHeader("Range", formatRange(mRange));
                }

                if (mIfNoneMatch != null) {
                    request.addHeader("If-None-Match", mIfNoneMatch);
                }
                request.setURI(URI.create(toUrl()));
            }

            if (mToken != null) {
                request.addHeader(ApiWrapper.createOAuthHeader(mToken));
            }
            return request;
        } catch (InstantiationException e) {
            throw new RuntimeException(e);
        } catch (IllegalAccessException e) {
            throw new RuntimeException(e);
        } catch (UnsupportedEncodingException e) {
            throw new RuntimeException(e);
        }
    }

    public static String formatRange(long... range) {
        switch (range.length) {
            case 0: return "bytes=0-";
            case 1:
                if (range[0] < 0) throw new IllegalArgumentException("negative range");
                return "bytes="+range[0]+"-";
            case 2:
                if (range[0] < 0) throw new IllegalArgumentException("negative range");
                if (range[0] > range[1]) throw new IllegalArgumentException(range[0] + ">" + range[1]);
                return "bytes="+range[0]+"-"+range[1];
            default: throw new IllegalArgumentException("invalid range specified");
        }
    }

    @Override public Iterator<NameValuePair> iterator() {
        return mParams.iterator();
    }

    @Override
    public String toString() {
        return "Request{" +
                 "mResource='" + mResource + '\'' +
                ", params=" + mParams +
                ", files=" + mFiles +
                ", entity=" + mEntity +
                ", mToken=" + mToken +
                ", listener=" + listener +
                '}';
    }

    /* package */ Token getToken() {
        return mToken;
    }

    /* package */ TransferProgressListener getListener() {
        return listener;
    }

    /**
     * Updates about the amount of bytes already transferred.
     */
    public static interface TransferProgressListener {
        /**
         * @param amount number of bytes already transferred.
         * @throws java.io.IOException if the transfer should be cancelled
         */
        public void transferred(long amount) throws IOException;
    }

    /* package */ static class ByteBufferBody extends AbstractContentBody {
        private ByteBuffer mBuffer;

        public ByteBufferBody(ByteBuffer buffer) {
            super("application/octet-stream");
            mBuffer = buffer;
        }

        @Override
        public String getFilename() {
            return null;
        }

        public String getTransferEncoding() {
            return MIME.ENC_BINARY;
        }

        public String getCharset() {
            return null;
        }

        @Override
        public long getContentLength() {
            return mBuffer.capacity();
        }

        @Override
        public void writeTo(OutputStream out) throws IOException {
            if (mBuffer.hasArray()) {
                out.write(mBuffer.array());
            } else {
                byte[] dst = new byte[mBuffer.capacity()];
                mBuffer.get(dst);
                out.write(dst);
            }
        }
    }

    /* package */ static class Attachment {
        public final File file;
        public final ByteBuffer data;
        public final String fileName;

        /** @noinspection UnusedDeclaration*/
        Attachment(File file) {
            this(file, file.getName());
        }

        Attachment(File file, String fileName) {
            if (file == null) throw  new IllegalArgumentException("file cannot be null");
            this.fileName = fileName;
            this.file = file;
            this.data = null;
        }

        /** @noinspection UnusedDeclaration*/
        Attachment(ByteBuffer data) {
            this(data, null);
        }

        Attachment(ByteBuffer data, String fileName) {
            if (data == null) throw new IllegalArgumentException("data cannot be null");

            this.data = data;
            this.fileName = fileName;
            this.file = null;
        }

        public ContentBody toContentBody() {
            if (file != null) {
                return new FileBody(file) {
                    @Override
                    public String getFilename() {
                        return fileName;
                    }
                };
            } else if (data != null) {
                return new ByteBufferBody(data) {
                    @Override
                    public String getFilename() {
                        return fileName;
                    }
                };
            } else {
                // never happens
                throw new IllegalStateException("no upload data");
            }
        }
    }

    /**
     * Returns a String that is suitable for use as an <code>application/x-www-form-urlencoded</code>
     * list of parameters in an HTTP PUT or HTTP POST.
     *
     * @param parameters  The parameters to include.
     * @param encoding The encoding to use.
     */
    public static String format(
            final List<? extends NameValuePair> parameters,
            final String encoding) {
        final StringBuilder result = new StringBuilder();
        for (final NameValuePair parameter : parameters) {
            final String encodedName = encode(parameter.getName(), encoding);
            final String value = parameter.getValue();
            final String encodedValue = value != null ? encode(value, encoding) : "";
            if (result.length() > 0)
                result.append("&");
            result.append(encodedName);
            if (value != null) {
                result.append("=");
                result.append(encodedValue);
            }
        }
        return result.toString();
    }

    private static String encode(final String content, final String encoding) {
        try {
            return URLEncoder.encode(content, encoding != null ? encoding : HTTP.DEFAULT_CONTENT_CHARSET);
        } catch (UnsupportedEncodingException problem) {
            throw new IllegalArgumentException(problem);
        }
    }
}
