package com.github.jreddit.oauth;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;

import javax.xml.bind.DatatypeConverter;

import org.apache.oltu.oauth2.client.OAuthClient;
import org.apache.oltu.oauth2.client.URLConnectionClient;
import org.apache.oltu.oauth2.client.request.OAuthClientRequest;
import org.apache.oltu.oauth2.client.request.OAuthClientRequest.TokenRequestBuilder;
import org.apache.oltu.oauth2.common.OAuthProviderType;
import org.apache.oltu.oauth2.common.exception.OAuthProblemException;
import org.apache.oltu.oauth2.common.exception.OAuthSystemException;
import org.apache.oltu.oauth2.common.message.types.GrantType;

import com.github.jreddit.oauth.app.RedditApp;
import com.github.jreddit.oauth.exception.RedditOAuthException;
import com.github.jreddit.oauth.param.RedditDuration;
import com.github.jreddit.oauth.param.RedditScopeBuilder;
import com.github.jreddit.request.util.KeyValueFormatter;

/**
 * Thread-safe reddit OAuth agent.

 *

 * Communicates with reddit to retrieve tokens, and converts them
 * into {@link RedditToken}s, which are used internally by jReddit. This class
 * supports both the code grant flow and implicit grant flow.
 *
 * @author Simon Kassing
 */
public class RedditOAuthAgent {

    /** Reddit authorization endpoint. */
    private static final String REDDIT_AUTHORIZE = "https://www.reddit.com/api/v1/authorize?";

    /** Grant type for an installed client (weirdly enough a URI). */
    private static final String GRANT_TYPE_INSTALLED_CLIENT = "https://oauth.reddit.com/grants/installed_client";

    /** Grant type for client credentials (described in OAuth2 standard). */
    private static final String GRANT_TYPE_CLIENT_CREDENTIALS = "client_credentials";

    /* Parameter keys */
    private static final String PARAM_CLIENT_ID = "client_id";
    private static final String PARAM_RESPONSE_TYPE = "response_type";
    private static final String PARAM_STATE = "state";
    private static final String PARAM_REDIRECT_URI = "redirect_uri";
    private static final String PARAM_DURATION = "duration";
    private static final String PARAM_SCOPE = "scope";
    private static final String PARAM_GRANT_TYPE = "grant_type";
    private static final String PARAM_CODE = "code";
    private static final String PARAM_DEVICE_ID = "device_id";

    /* Header keys */
    private static final String HEADER_USER_AGENT = "User-Agent";
    private static final String HEADER_AUTHORIZATION = "Authorization";

    /** User agent. */
    private final String userAgent;

    /** OAuth2 client for OAuth related requests. */
    private OAuthClient oAuthClient;

    /** Reddit application. */
    private RedditApp redditApp;

    /**
     * Constructor for a Reddit OAuth agent.

     *

     * A default Apache OAuthClient will be made to perform the OAuth communication.
     *
     * @param userAgent User agent for your application (e.g. "jReddit: Reddit API Wrapper for Java")
     * @param redditApp Reddit application
     */
    public RedditOAuthAgent(String userAgent, RedditApp redditApp) {
        this(userAgent, redditApp, new OAuthClient(new URLConnectionClient()));
    }

    /**
     * Constructor for a Reddit OAuth agent.
     *
     * @param userAgent User agent for your application (e.g. "jReddit: Reddit API Wrapper for Java")
     * @param redditApp Reddit application
     * @param oAuthClient Apache OAuth2 client
     */
    public RedditOAuthAgent(String userAgent, RedditApp redditApp, OAuthClient oAuthClient) {
        this.userAgent = userAgent;
        this.redditApp = redditApp;
        this.oAuthClient = oAuthClient;
    }

    /**
     * Generate the code flow Uniform Resource Locator (URI) for a
     * reddit user to authorize your application.

     *

     * The user will, after authorization, receive a code. This can be turned into
     * a RedditToken using {@link #token(String)}.
     *
     * @param scopeBuilder Authorization scope builder (must not be null)
     * @param duration Duration that the token can last
     *
     * @return The URI users need to visit and retrieve the code from
     *
     * @see {@link #token(String)} for converting the code into a usable RedditToken
     */
    public synchronized String generateCodeFlowURI(RedditScopeBuilder scopeBuilder, RedditDuration duration) {

        // Set parameters
        Map<String, String> params = new HashMap<String, String>();
        params.put(PARAM_CLIENT_ID, redditApp.getClientID());
        params.put(PARAM_RESPONSE_TYPE, "code");
        params.put(PARAM_STATE, UUID.randomUUID().toString());
        params.put(PARAM_REDIRECT_URI, redditApp.getRedirectURI());
        params.put(PARAM_DURATION, duration.value());
        params.put(PARAM_SCOPE, scopeBuilder.build());

        // Create URI
        return REDDIT_AUTHORIZE + KeyValueFormatter.format(params, true);

    }

    /**
     * Generate the implicit flow Uniform Resource Locator (URI) for a
     * reddit user to authorize your application.

     *

     * The user will, after authorization, receive token information. This can be turned into
     * a RedditToken using {@link #tokenFromInfo(String, String, long, String)}.
     *
     * @param scopeBuilder Authorization scope builder (must not be null)
     *
     * @return The URI users need to visit and retrieve the token information from
     *
     * @see {@link #tokenFromInfo(String, String, long, String)} for converting the
     * token information into RedditToken
     */
    public synchronized String generateImplicitFlowURI(RedditScopeBuilder scopeBuilder) {

        // Set parameters
        Map<String, String> params = new HashMap<String, String>();
        params.put(PARAM_CLIENT_ID, redditApp.getClientID());
        params.put(PARAM_RESPONSE_TYPE, "token");
        params.put(PARAM_STATE, UUID.randomUUID().toString());
        params.put(PARAM_REDIRECT_URI, redditApp.getRedirectURI());
        params.put(PARAM_SCOPE, scopeBuilder.build());

        // Create URI
        return REDDIT_AUTHORIZE + KeyValueFormatter.format(params, true);

    }

    /**
     * Add a user agent to the OAuth request.
     *
     * @param request OAuth request
     */
    private void addUserAgent(OAuthClientRequest request) {
        request.addHeader(HEADER_USER_AGENT, userAgent);
    }

    /**
     * Add the basic authentication protocol to the OAuth request using
     * the credentials of the Reddit application provided.
     *
     * @param request OAuth request
     * @param app Reddit application
     */
    private void addBasicAuthentication(OAuthClientRequest request, RedditApp app) {
        String authString = app.getClientID() + ":" + app.getClientSecret();
        String authStringEnc = DatatypeConverter.printBase64Binary(authString.getBytes());
        request.addHeader(HEADER_AUTHORIZATION, "Basic " + authStringEnc);
    }

    /**
     * Token retrieval (code flow).

     *

     * Retrieve a token for a specific user, meaning that the token is coupled to a user.
     * After it has expired, the token will no longer work. You must either request a new
     * token, or refresh it using {@link #refreshToken(RedditToken)}.
     *
     * @param code One-time code received from the user, after manual authorization by that user
     *
     * @return Token (associated with a user)
     *
     * @throws RedditOAuthException
     */
    public synchronized RedditToken token(String code) throws RedditOAuthException {

        try {

            // Set general values of the request
            OAuthClientRequest request = OAuthClientRequest
                .tokenProvider(OAuthProviderType.REDDIT)
                .setGrantType(GrantType.AUTHORIZATION_CODE)
                .setClientId(redditApp.getClientID())
                .setClientSecret(redditApp.getClientSecret())
                .setRedirectURI(redditApp.getRedirectURI())
                .setParameter(PARAM_CODE, code)
                .buildBodyMessage();

            // Add the user agent
            addUserAgent(request);

            // Add basic authentication
            addBasicAuthentication(request, redditApp);

            // Return a wrapper controlled by jReddit
            return new RedditToken(oAuthClient.accessToken(request));

        } catch (OAuthSystemException oase) {
            throw new RedditOAuthException(oase);
        } catch (OAuthProblemException oape) {
            throw new RedditOAuthException(oape);
        }

    }

    /**
     * Refresh token.

     *

     * This is only possible for tokens retrieved through the code flow
     * authorization and had their duration set to permanent. Tokens that do not have
     * a refresh_token with them or are expired, will not be able to be refreshed.
     * In that case, a new one must be acquired.
     *
     * @param rToken Reddit token (which needs to be refreshed)
     *
     * @return Whether the token was successfully refreshed
     *
     * @throws RedditOAuthException
     *
     * @see RedditToken#isRefreshable()
     */
    public synchronized boolean refreshToken(RedditToken rToken) throws RedditOAuthException {

        try {

            // Check whether the token can be refreshed
            if (rToken.isRefreshable()) {

                // Set general values of the request
                OAuthClientRequest request = OAuthClientRequest
                    .tokenProvider(OAuthProviderType.REDDIT)
                    .setGrantType(GrantType.REFRESH_TOKEN)
                    .setRefreshToken(rToken.getRefreshToken())
                    .buildBodyMessage();

                // Add the user agent
                addUserAgent(request);

                // Add basic authentication
                addBasicAuthentication(request, redditApp);

                // Return a wrapper controlled by jReddit
                rToken.refresh(oAuthClient.accessToken(request));

                return true;

            } else {

                // The token cannot be refreshed
                return false;

            }

        } catch (OAuthSystemException oase) {
            throw new RedditOAuthException(oase);
        } catch (OAuthProblemException oape) {
            throw new RedditOAuthException(oape);
        }

    }

    /**
     * Token retrieval (app-only flow).

     *

     * Retrieve a token for the application-only, meaning that the
     * token is not coupled to any user. The token is typically only
     * valid for a short period of time (at the moment of writing: 1 hour).
     * After it has expired, the token will no longer work. You must request a new
     * token in that case. Refreshing an application-only token is not possible.
     *
     * @param confidential True: confidential clients (web apps / scripts) not acting on
     *                     behalf of one or more logged out users. False: installed app types,
     *                     and other apps acting on behalf of one or more logged out users.
     *
     * @return Token (not associated with a user)
     *
     * @throws RedditOAuthException
     */
    public synchronized RedditToken tokenAppOnly(boolean confidential) throws RedditOAuthException {

        try {

            // Set general values of the request
            TokenRequestBuilder builder = OAuthClientRequest
                .tokenProvider(OAuthProviderType.REDDIT)
                .setParameter(PARAM_GRANT_TYPE, confidential ? GRANT_TYPE_CLIENT_CREDENTIALS : GRANT_TYPE_INSTALLED_CLIENT)
                .setClientId(redditApp.getClientID())
                .setClientSecret(redditApp.getClientSecret());

            // If it is not acting on behalf of a unique client, a unique device identifier must be generated:
            if (!confidential) {
                builder = builder.setParameter(PARAM_DEVICE_ID, UUID.randomUUID().toString());
            }

            // Build the request
            OAuthClientRequest request = builder.buildBodyMessage();

            // Add the user agent
            addUserAgent(request);

            // Add basic authentication
            addBasicAuthentication(request, redditApp);

            // Return a wrapper controlled by jReddit
            return new RedditToken(oAuthClient.accessToken(request));

        } catch (OAuthSystemException oase) {
            throw new RedditOAuthException(oase);
        } catch (OAuthProblemException oape) {
            throw new RedditOAuthException(oape);
        }

    }

    /**
     * Generate a token from information received using the implicit grant flow.

     *

     * WARNING: The expiration of the token is no longer very accurate. There is a delay
     * between the user receiving the token, and inputting it into this function. Beware that the
     * token might expire earlier than that the token reports it to.
     *
     * @param accessToken Access token
     * @param tokenType Token type (commonly "bearer")
     * @param expiresIn Expires in (seconds)
     * @param scope Scope
     *
     * @return RedditToken generated using the given information.
     */
    public synchronized RedditToken tokenFromInfo(String accessToken, String tokenType, long expiresIn, String scope) {
        return new RedditToken(accessToken, tokenType, expiresIn, scope);
    }

    /**
     * Revocation of a RedditToken.

     *

     * Be sure to not use the token after
     * calling this function, as its state pertaining its validity (e.g. scope,
     * expiration, refreshability) is no longer valid when it is revoked.

     *

     * Note: Per RFC 7009, this request will return a success (204) response
     * even if the passed in token was never valid.
     *
     * @param token RedditToken to revoke
     * @param revokeAccessTokenOnly Whether to only revoke the access token, or both
     *
     * @return Whether the token is no longer valid
     */
    public boolean revoke(RedditToken token, boolean revokeAccessTokenOnly) {
        // TODO: Implement
        // https://www.reddit.com/api/v1/revoke_token
        // In POST data: token=TOKEN&token_type_hint=TOKEN_TYPE
        // TOKEN_TYPE: refresh_token or access_token
        //
        return true;
    }

}


package com.github.jreddit.oauth;

import org.apache.oltu.oauth2.client.response.OAuthJSONAccessTokenResponse;

import com.github.jreddit.oauth.param.RedditScope;
import com.github.jreddit.oauth.param.RedditTokenCompleteScope;

/**
 * OAuth2 token wrapper for reddit.

 *

 * This class wraps the information received from reddit following
 * the request for a token.

 * A token has three dimensions:
 *

 *
scope: what can it be used for?
 *
expiration: how long can it be used?
 *
refreshable: can its duration be prolonged?
 *

 *
 * @author Simon Kassing
 *
 */
public class RedditToken {

    /** Token type parameter. */
    public static final String PARAM_TOKEN_TYPE = "token_type";

    /** Access token. */
    private String accessToken;

    /** Refresh token. */
    private String refreshToken;

    /** Manager of the scopes that this token applies to. */
    private RedditTokenCompleteScope scopes;

    /** Token type. Only value possible (15-06-2015): bearer */
    private String tokenType;

    /** Time at which the token expires (seconds since UNIX epoch). */
    private long expiration;

    /** How long the token was valid starting from its creation (in seconds). */
    private long expirationSpan;

    /**
     * @param token JSON response after an OAuth2 token request
     */
    protected RedditToken(OAuthJSONAccessTokenResponse token) {
        this.accessToken = token.getAccessToken();
        this.refreshToken = token.getRefreshToken();
        this.expiration = currentTimeSeconds() + token.getExpiresIn();
        this.expirationSpan = token.getExpiresIn();
        this.scopes = new RedditTokenCompleteScope(token.getScope());
        this.tokenType = token.getParam(PARAM_TOKEN_TYPE);
    }

    /**
     * Reddit token constructor with specific user-provided parameters.
     *
     * @param accessToken Access token
     * @param tokenType Type of token (commonly "bearer")
     * @param expiresIn Expires in (seconds)
     * @param scope Scope
     */
    protected RedditToken(String accessToken, String tokenType, long expiresIn, String scope) {
        this.accessToken = accessToken;
        this.refreshToken = null;
        this.expiration = currentTimeSeconds() + expiresIn;
        this.expirationSpan = expiresIn;
        this.scopes = new RedditTokenCompleteScope(scope);
        this.tokenType = tokenType;
    }

    /**
     * Refresh this reddit token with data received from the new token.
     *
     * @param token Token received from a refresh request to reddit
     */
    public void refresh(OAuthJSONAccessTokenResponse token) {
        this.accessToken = token.getAccessToken();
        this.expiration = currentTimeSeconds() + token.getExpiresIn();
        this.expirationSpan = token.getExpiresIn();
        this.scopes = new RedditTokenCompleteScope(token.getScope());
        this.tokenType = token.getParam(PARAM_TOKEN_TYPE);
    }

    /**
     * Retrieve the access token.
     *
     * @return Access token (e.g. "jsdkjfskaj9f8-dnafk")
     */
    public String getAccessToken() {
        return accessToken;
    }

    /**
     * Retrieve the refresh token.
     *
     * @return Refresh token (e.g. "nvkJu9kjdada2-d98afj")
     */
    public String getRefreshToken() {
        return refreshToken;
    }

    /**
     * Retrieve whether the token is expired.
     *
     * @return Is the token expired?
     */
    public boolean isExpired() {
        return expiration < currentTimeSeconds();
    }

    /**
     * Check whether this token possess this particular authorization scope.
     * If it does not support the scope, it means that the token does
     * not have approval from the user to perform the actions belonging to
     * that scope.
     *
     * @param scope Reddit scope
     *
     * @return Does this token support this scope?
     */
    public boolean hasScope(RedditScope scope) {
        return this.scopes.has(scope);
    }

    /**
     * Retrieve the expiration.
     *
     * @return Expiration time (seconds since UNIX epoch)
     */
    public long getExpiration() {
        return expiration;
    }
    /**
     * Retrieve the token type.
     *
     * @return Token type (e.g. "bearer")
     */
    public String getTokenType() {
        return tokenType;
    }

    /**
     * Check whether the token can be refreshed.
     * This means that a refresh token exists.
     *
     * @return Can the token be refreshed?
     */
    public boolean isRefreshable() {
        return this.getRefreshToken() != null;
    }

    /**
     * Check whether this token will expire within the given time frame (given in seconds).
     *
     * @param seconds Amount of seconds
     *
     * @return Will the token expire within the given time frame?
     */
    public boolean willExpireIn(long seconds) {
        return currentTimeSeconds() + seconds > expiration;
    }

    /**
     * Retrieve the original expiration span of the token starting from its creation.
     *
     * @return Expiration span (in seconds)
     */
    public long getExpirationSpan() {
        return expirationSpan;
    }

    /**
     * Retrieve the current time in seconds.
     *
     * Uses System.currentTimeMillis().
     *
     * @return Current time in seconds.
     */
    private static long currentTimeSeconds() {
        return System.currentTimeMillis() / (long) 1000;
    }

}


package com.github.jreddit.parser.util;

import java.util.ArrayList;
import java.util.List;

import com.github.jreddit.parser.entity.Comment;
import com.github.jreddit.parser.entity.imaginary.CommentTreeElement;

public class CommentTreeUtils {

    private CommentTreeUtils() {
        // Empty to disallow the invocation of the default constructor for this utility class
    }

    /**
     * Flatten the comment tree.
     * The original comment tree is not overwritten.
     *
     * @param cs        List of comments that you get returned from one of the other methods here
     *
     * @return    Flattened comment tree.
     */
    public static List<CommentTreeElement> flattenCommentTree(List<CommentTreeElement> commentTree) {
        List<CommentTreeElement> target = new ArrayList<CommentTreeElement>();
        flattenCommentTree(commentTree, target);
        return target;
    }

    /**
     * Flatten the comment tree.
     * The original comment tree is not overwritten.
     *
     * @param cs        List of comments that you get returned from one of the other methods here
     * @param target    List in which to place the flattened comment tree.
     */
    private static void flattenCommentTree(List<CommentTreeElement> commentTree, List<CommentTreeElement> target) {
        for (CommentTreeElement c : commentTree) {
            target.add(c);
            if (c instanceof Comment) {
                flattenCommentTree(((Comment)c).getReplies(), target);
            }
        }
    }

    /**
     * Get printable version of the given comment tree.
     *
     * @param cs     List of comment tree elements
     *
     * @return Printable comment tree
     */
    public static String printCommentTree(List<CommentTreeElement> cs) {
        StringBuilder builder = new StringBuilder();
        for (CommentTreeElement c : cs) {
            builder.append(printCommentTree(c, 0));
        }
        return builder.toString();
    }

    /**
     * Get printable version of a comment at a specific level.

     *

     * Note: uses unsafe recursion
     *
     * @param c            Comment
     * @param level        Level to place at
     *
     * @return Printable comment tree
     */
    private static String printCommentTree(CommentTreeElement c, int level) {

        // Initialize empty buffer
        StringBuilder builder = new StringBuilder();

        // Add tabulation
        for (int i = 0; i < level; i++) {
            builder.append("\t");
        }

        // Comment string
        builder.append(c.toString());
        builder.append("\n");

        // Iterate over children
        if (c instanceof Comment) {
            for (CommentTreeElement child : ((Comment) c).getReplies()) {
                builder.append(printCommentTree(child, level + 1));
            }
        }

        // Return the buffer
        return builder.toString();

    }

}


package com.github.jreddit.parser.util;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * Safe utilities (not throwing exceptions) for the conversion of JSON
 * data into basic types such as Integer, Boolean, Long, and Double.
 */
public final class JsonUtils {

    private static final Logger LOGGER = LoggerFactory.getLogger(JsonUtils.class);

    private JsonUtils() {
        // forbid creating JsonUtils instance
    }

    /**
     * Safely converts an object into string (used because sometimes JSONObject's get() method returns null).
     *
     * @param obj The object to convert.
     * @return The string.
     */
    public static String safeJsonToString(Object obj) {
        return obj == null ? null : obj.toString();
    }

    /**
     * Safely converts an object into an integer
     *
     * @param obj The object to convert.
     * @return an Integer representing the integer value of the Object (null if the object cannot be converted to an Integer)
     */
    public static Integer safeJsonToInteger(Object obj) {
        Integer intValue = null;

        try {
            String str = safeJsonToString(obj);
            intValue = str != null ? Integer.parseInt(str) : null;
        } catch (NumberFormatException e) {
            LOGGER.warn("Safe JSON conversion to Integer failed", e);
        }

        return intValue;
    }

    /**
     * Safely converts an object into an double
     *
     * @param obj The object to convert.
     * @return a Double representing the double value of the Object (null if the object cannot be converted to Double)
     */
    public static Double safeJsonToDouble(Object obj) {
        Double doubleValue = null;

        try {
            String str = safeJsonToString(obj);
            doubleValue = str != null ? Double.parseDouble(str) : null;
        } catch (NumberFormatException e) {
            LOGGER.warn("Safe JSON conversion to Double failed", e);
        }

        return doubleValue;
    }

    /**
     * Safely converts an object into an boolean
     *
     * @param obj The object to convert.
     * @return a Boolean representing the boolean value of the Object (null only if the object was also null)
     */
    public static Boolean safeJsonToBoolean(Object obj) {
        String str = safeJsonToString(obj);
        Boolean booleanValue = str != null ? Boolean.parseBoolean(str) : null;
        return booleanValue;
    }

    /**
     * Safely converts an object into an long
     *
     * @param obj The object to convert.
     * @return a Long representing the long value of the Object (null if the object cannot be converted to Long)
     */
    public static Long safeJsonToLong(Object obj) {
        Long longValue = null;

        try {
            String str = safeJsonToString(obj);
            longValue = str != null ? Long.parseLong(str) : null;
        } catch (NumberFormatException e) {
            LOGGER.warn("Safe JSON conversion to Long failed", e);
        }

        return longValue;
    }

}


package com.github.jreddit.parser.listing;

import java.util.LinkedList;
import java.util.List;

import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.jreddit.parser.entity.Comment;
import com.github.jreddit.parser.entity.Thing;
import com.github.jreddit.parser.exception.RedditParseException;

public class CommentsListingParser extends RedditListingParser {

    private static final Logger LOGGER = LoggerFactory.getLogger(CommentsListingParser.class);

    /**
     * Parse JSON received from reddit into a list of comments.
     * This parser expects the JSON to be of a listing of comments.

     *

     * Note: this parsing can only be performed on listings of comments, not on
     * a comment tree of a submission.
     *
     * @param jsonText JSON Text
     * @return Parsed list of comments
     *
     * @throws ParseException
     * @throws RedditRequestException
     */
    public List<Comment> parse(String jsonText) throws RedditParseException {

        // Parse to a list of things
        List<Thing> things = this.parseGeneric(jsonText);

        // List of comment and submission mixed elements
        List<Comment> comments = new LinkedList<Comment>();

        // Iterate over things
        for (Thing t : things) {

            if (t instanceof Comment) {
                comments.add((Comment) t);
            } else {
                LOGGER.warn("Encountered an unexpected reddit thing (" + t.getKind().value() + "), skipping it.");
            }

        }

        // Return resulting comments list
        return comments;

    }

}


package com.github.jreddit.parser.single;

import static com.github.jreddit.parser.util.JsonUtils.safeJsonToString;

import java.util.ArrayList;
import java.util.List;

import org.json.simple.JSONArray;
import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;

import com.github.jreddit.parser.entity.Comment;
import com.github.jreddit.parser.entity.Kind;
import com.github.jreddit.parser.entity.More;
import com.github.jreddit.parser.entity.Submission;
import com.github.jreddit.parser.entity.imaginary.CommentTreeElement;
import com.github.jreddit.parser.entity.imaginary.FullSubmission;
import com.github.jreddit.parser.exception.RedditParseException;
import com.github.jreddit.parser.util.JsonUtils;

public class FullSubmissionParser {

    protected static final JSONParser JSON_PARSER = new JSONParser();

    /**
     * Parse JSON received from reddit into a full submission.
     * A full submissions means it has both (a) the submission, and (b) the comment tree.
     *
     * @param jsonText JSON Text
     * @return Full submission
     *
     * @throws ParseException
     */
    public FullSubmission parse(String jsonText) throws RedditParseException {

        try {

            // Parse JSON text
            Object response = JSON_PARSER.parse(jsonText);

            // Validate response
            validate(response);

            // Create submission (casting with JSON is horrible)
            JSONObject main = (JSONObject) ((JSONArray) response).get(0);
            Submission submission = new Submission((JSONObject) ((JSONObject) ((JSONArray)((JSONObject) main.get("data")).get("children")).get(0)).get("data"));

            // Create comment tree
            JSONObject mainTree =  (JSONObject) ((JSONArray) response).get(1);
            List<CommentTreeElement> commentTree = parseRecursive(mainTree);

            // Return the set of submission and its comment tree
            return new FullSubmission(submission,  commentTree);

        } catch (ParseException pe) {
            throw new RedditParseException(pe);
        }

    }

    /**
     * Parse a JSON object consisting of comments and add them
     * to the already existing list of comments. This does NOT create
     * a new comment list.
     *
     * @param comments     List of comments
     * @param object    JSON Object
     */
    protected List<CommentTreeElement> parseRecursive(JSONObject main) throws RedditParseException {

        List<CommentTreeElement> commentTree = new ArrayList<CommentTreeElement>();

        // Iterate over the comment tree results
        JSONArray array = (JSONArray) ((JSONObject) main.get("data")).get("children");
        for (Object element : array) {

            // Get the element
            JSONObject data = (JSONObject) element;

            // Make sure it is of the correct kind
            String kind = safeJsonToString(data.get("kind"));

            // If it is a comment
            if (kind != null && kind.equals(Kind.COMMENT.value())) {

                // Create comment
                Comment comment = new Comment( (JSONObject) data.get("data") );

                // Retrieve replies
                Object replies = ((JSONObject) data.get("data")).get("replies");

                // If it is an JSON object
                if (replies instanceof JSONObject) {
                    comment.setReplies(parseRecursive( (JSONObject) replies ));

                // If there are no replies, end with an empty one
                } else {
                    comment.setReplies(new ArrayList<CommentTreeElement>());
                }

                // Add comment to the tree
                commentTree.add(comment);
            }

            // If it is a more
            if (kind != null && kind.equals(Kind.MORE.value())) {

                // Add to comment tree
                commentTree.add(new More((JSONObject) data.get("data")));

            }

        }

        return commentTree;

    }

    /**
     * Validate that it is in fact a full submission.
     *
     * @param response Object from the JSON parser
     *
     * @throws RedditRequestException If the JSON is in incorrect format
     */
    public void validate(Object response) throws RedditParseException {

        // Check for null
        if (response == null) {
            throw new RedditParseException();
        }

        // Check it is a JSON response
        if (response instanceof JSONObject) {

            // Cast to JSON object
            JSONObject jsonResponse = (JSONObject) response;

            // Check for error
            if (jsonResponse.get("error") != null) {
                throw new RedditParseException(JsonUtils.safeJsonToInteger(jsonResponse.get("error")));
            } else {
                throw new RedditParseException("invalid json format, started with object (should start with array)");
            }

        }

        // It must start with an array
        if (!(response instanceof JSONArray)) {
            throw new RedditParseException("invalid json format, did not start with array");
        }

    }

}


package com.github.jreddit.parser.listing;

import java.util.LinkedList;
import java.util.List;

import org.json.simple.parser.ParseException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import com.github.jreddit.parser.entity.Subreddit;
import com.github.jreddit.parser.entity.Thing;
import com.github.jreddit.parser.exception.RedditParseException;

public class SubredditsListingParser extends RedditListingParser {

    private static final Logger LOGGER = LoggerFactory.getLogger(SubredditsListingParser.class);

    /**
     * Parse JSON received from reddit into a list of subreddits.
     * This parser expects the JSON to be of a listing of subreddits.
     *
     * @param jsonText JSON Text
     * @return Parsed list of subreddits
     *
     * @throws ParseException
     */
    public List<Subreddit> parse(String jsonText) throws RedditParseException {

        // Parse to a list of things
        List<Thing> things = this.parseGeneric(jsonText);

        // List of comment and submission mixed elements
        List<Subreddit> subreddits = new LinkedList<Subreddit>();

        // Iterate over things
        for (Thing t : things) {

            if (t instanceof Subreddit) {
                subreddits.add((Subreddit) t);
            } else {
                LOGGER.warn("Encountered an unexpected reddit thing (" + t.getKind().value() + "), skipping it.");
            }

        }

        // Return resulting comments list
        return subreddits;

    }

}


package org.jinstagram.utils;

import java.nio.charset.Charset;
import java.security.InvalidKeyException;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

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

import org.apache.commons.codec.binary.Hex;
import org.apache.commons.lang3.StringUtils;
import org.jinstagram.exceptions.InstagramException;

public class EnforceSignedRequestUtils {

    private static final String HMAC_SHA256 = "HmacSHA256";

    private static final Charset UTF_8 = Charset.forName("UTF-8");

    /**
     * signature returns the HmacSHA256 encoded signature used for signed Instagram requests
     * @param endpoint The endpoint
     * @param params   The query parameters
     * @param clientSecret The client application secret
     * @return encoded signature String
     * @throws InstagramException
     */
    public static String signature(String endpoint, Map<String, String> params, String clientSecret) throws InstagramException {
        SecretKeySpec keySpec = new SecretKeySpec(clientSecret.getBytes(UTF_8), HMAC_SHA256);

        // ensure we iterate through the keys in sorted order
        List<String> values = new ArrayList<String>(params.size());
        for (String key : MapUtils.getSortedKeys(params)) {
            values.add(String.format("%s=%s", key, params.get(key)));
        }

        // the sig string to sign in the form "endpoint|key1=value1|key2=value2|...."
        String sig = String.format("%s|%s", endpoint, StringUtils.join(values, '|'));

        try {
            Mac mac = Mac.getInstance(HMAC_SHA256);
            mac.init(keySpec);
            byte[] result = mac.doFinal(sig.getBytes(UTF_8));
            return Hex.encodeHexString(result);
        } catch (NoSuchAlgorithmException e) {
            throw new InstagramException("Invalid algorithm name!", e);
        } catch (InvalidKeyException e) {
            throw new InstagramException("Invalid key: " + clientSecret, e);
        }
    }

}


package org.jinstagram.utils;

import org.jinstagram.http.URLUtils;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

/**
 * Utils for {@link Map} manipulation
 */
public class MapUtils {

    /**
     * Sorts a Map
     *
     * @param map unsorted map
     * @return sorted map
     */
    public static Map<String, String> sort(Map<String, String> map) {
        Preconditions.checkNotNull(map, "Cannot sort a null object.");

        Map<String, String> sorted = new LinkedHashMap<String, String>();

        for (String key : getSortedKeys(map)) {
            sorted.put(key, map.get(key));
        }

        return sorted;
    }

    /*package private*/ static List<String> getSortedKeys(Map<String, String> map) {
        List<String> keys = new ArrayList<String>(map.keySet());

        Collections.sort(keys);

        return keys;
    }

    /**
     * Form-urlDecodes and appends all keys from the source {@link Map} to the
     * target {@link Map}
     *
     * @param source Map from where the keys get copied and decoded
     * @param target Map where the decoded keys are copied to
     */
    public static void decodeAndAppendEntries(Map<String, String> source,
                                              Map<String, String> target) {
        for (Entry<String, String> entry : source.entrySet()) {
            target.put(URLUtils.percentEncode(entry.getKey()),
                    URLUtils.percentEncode(entry.getValue()));
        }
    }

    public static <K, V> String toString(Map<K, V> map) {
        if (map == null) {
            return "";
        }
        if (map.isEmpty()) {
            return "{}";
        }

        StringBuilder result = new StringBuilder();
        for (Map.Entry<K, V> entry : map.entrySet()) {
            result.append(
                    String.format(", %s -> %s ", entry.getKey().toString(), entry.getValue().toString()));
        }
        return "{" + result.substring(1) + "}";
    }
}


package org.jinstagram;

import java.io.IOException;
import java.util.Map;

import org.jinstagram.auth.model.OAuthRequest;
import org.jinstagram.entity.common.InstagramErrorResponse;
import org.jinstagram.entity.oembed.OembedInformation;
import org.jinstagram.exceptions.InstagramException;
import org.jinstagram.http.Response;
import org.jinstagram.http.Verbs;
import org.jinstagram.model.Constants;
import org.jinstagram.model.Methods;

import com.google.gson.Gson;

/**
 * Instagram Oembed
 *
 * @author Sachin Handiekar
 */
public class InstagramOembed {
    /**
     * Returns information about the media associated with that link.
     *
     * @param url A short link, like http://instagr.am/p/BUG/.
     * @return OembedInformation object
     * @throws org.jinstagram.exceptions.InstagramException if any error occurs.
     */
    public OembedInformation getOembedInformation(String url) throws InstagramException {
        String apiMethod = String.format(Methods.OEMBED_INFORMATION, url);
        return createInstagramObject(Verbs.GET, OembedInformation.class, apiMethod, null);
    }

    /**
     * Create an Instagram object based on class-name and response.
     *
     * @param verbs HTTP State
     * @param clazz
     * @param methodName
     * @param params
     * @return
     * @throws InstagramException
     */
    private static <T> T createInstagramObject(Verbs verbs, Class<T> clazz, String methodName, Map<String, String> params)
            throws InstagramException {
        Response response;
        try {
            response = getApiResponse(verbs, methodName, params);
        } catch (IOException e) {
            throw new InstagramException("IOException while retrieving data", e);
        }

        if (response.getCode() >= 200 && response.getCode() < 300) {
            return createObjectFromResponse(clazz, response.getBody());
        }

        throw handleInstagramError(response);
    }

    /**
     *
     * @param response
     * @return
     * @throws InstagramException
     */
    private static InstagramException handleInstagramError(Response response) throws InstagramException {
        if (response.getCode() == 400) {
            Gson gson = new Gson();
            final InstagramErrorResponse error = InstagramErrorResponse.parse(gson, response.getBody());
            error.setHeaders(response.getHeaders());
            error.throwException();
        }
        throw new InstagramException("Unknown error response code: " + response.getCode() + " " + response.getBody(),
                response.getHeaders());
    }

    /**
     * Get response from Instagram.
     *
     * @param verb HTTP Verb
     * @param methodName Instagram API Method
     * @param params parameters which would be sent with the request.
     * @return Response object.
     */
    private static Response getApiResponse(Verbs verb, String methodName, Map<String, String> params) throws IOException {
        Response response;
        String apiResourceUrl = Constants.OEMBED_URL + methodName;
        OAuthRequest request = new OAuthRequest(verb, apiResourceUrl);

        // Additional parameters in url
        if (params != null) {
            for (Map.Entry<String, String> entry : params.entrySet()) {
                if (verb == Verbs.GET) {
                    request.addQuerystringParameter(entry.getKey(), entry.getValue());
                } else {
                    request.addBodyParameter(entry.getKey(), entry.getValue());
                }
            }
        }

        response = request.send();

        return response;
    }

    /**
     * Creates an object from the JSON response and the class which the object would be mapped to.
     *
     * @param clazz a class instance
     * @param response a JSON feed
     * @return a object of type <T>
     * @throws InstagramException if any error occurs.
     */
    private static <T> T createObjectFromResponse(Class<T> clazz, final String response) throws InstagramException {
        Gson gson = new Gson();
        T object;

        try {
            object = gson.fromJson(response, clazz);
        } catch (Exception e) {
            throw new InstagramException("Error parsing json to object type " + clazz.getName(), e);
        }

        return object;
    }
}


package org.jinstagram.utils;

import java.util.regex.Pattern;

import org.apache.commons.lang3.StringUtils;
import org.jinstagram.auth.model.OAuthConstants;

/**
 * Utils for checking preconditions and invariants
 */
public class Preconditions {
	private static final String DEFAULT_MESSAGE = "Received an invalid parameter";

	private static final Pattern URL_PATTERN = Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://\\S+");
	private static final Pattern LAT_LONG_PATTERN = Pattern.compile("(-)?[0-9]+(\\.)?[0-9]*");
	private static final Pattern NUMERIC_PATTERN = Pattern.compile("[0-9]+");

	/**
	 * Checks that an object is not null.
	 *
	 * @param object   any object
	 * @param errorMsg error message
	 * @throws IllegalArgumentException if the object is null
	 */
	public static void checkNotNull(Object object, String errorMsg) {
		check(object != null, errorMsg);
	}

	/**
	 * Checks that at least one of object1 or object2 is not null
	 *
	 * @param object1 any object
	 * @param object2 any object
	 * @param errorMsg error message
	 * @throws IllegalArgumentException if both object1 and object2 are null
	 */
	public static void checkBothNotNull(Object object1, Object object2, String errorMsg) {
		check(!(object1 == null && object2 == null), errorMsg);
	}

	/**
	 * Checks that a string is not null or empty
	 * @param string   any string
	 * @param errorMsg error message
	 * @throws IllegalArgumentException if the string is null or empty
	 */
	public static void checkEmptyString(String string, String errorMsg) {
		check(StringUtils.isNotBlank(string), errorMsg);
	}

	/**
	 * Checks that a URL is valid
	 *
	 * @param url      any string
	 * @param errorMsg error message
	 */
	public static void checkValidUrl(String url, String errorMsg) {
		checkEmptyString(url, errorMsg);
		check(isUrl(url), errorMsg);
	}

	/**
	 * Checks that a URL is a valid OAuth callback
	 *
	 * @param url      any string
	 * @param errorMsg error message
	 */
	public static void checkValidOAuthCallback(String url, String errorMsg) {
		checkEmptyString(url, errorMsg);

		if (url.toLowerCase().compareToIgnoreCase(OAuthConstants.OUT_OF_BAND) != 0) {
			check(isUrl(url), errorMsg);
		}
	}

	/**
	 * Checks that a string is a valid longitude or latitude value ('lat' and 'lng')
	 * as shown in Instagram Developer real time section
	 *
	 * @param latOrLong any string
	 * @param errorMsg error message
	 */
	public static void checkValidLatLong(String latOrLong, String errorMsg) {
		checkEmptyString(latOrLong, errorMsg);
		check(isLatLong(latOrLong), errorMsg);
	}

	/**
	 * Check that a string is a valid radius value ('radius')
	 * as shown in Instagram Developer real time section
	 *
	 * @param radiusString any string that is supposed to be a radius
	 * @param errorMsg error message
	 */
	public static void checkValidRadius(String radiusString, String errorMsg) {
		checkEmptyString(radiusString, errorMsg);
		check(isNumeric(radiusString), errorMsg);
	}

	private static boolean isUrl(String url) {
		return URL_PATTERN.matcher(url).matches();
	}

	private static boolean isLatLong(String latOrLong) {
		return LAT_LONG_PATTERN.matcher(latOrLong).matches();
	}

	private static boolean isNumeric(String numericString) {
		return NUMERIC_PATTERN.matcher(numericString).matches();
	}

	private static void check(boolean requirements, String error) {
		String message = StringUtils.isBlank(error) ? DEFAULT_MESSAGE : error;

		if (!requirements) {
			throw new IllegalArgumentException(message);
		}
	}
}


package org.jinstagram.auth;

import java.net.Proxy;

import org.jinstagram.auth.model.OAuthConfig;
import org.jinstagram.auth.model.OAuthConstants;
import org.jinstagram.auth.oauth.InstagramService;
import org.jinstagram.auth.oauth.OAuthService;
import org.jinstagram.utils.Preconditions;

public class InstagramAuthService {
	private String apiKey;

	private String apiSecret;

	private String callback;

	private String display;

	private String scope;

	private Proxy requestProxy;

	/**
	 * Default constructor
	 */
	public InstagramAuthService() {
		this.callback = OAuthConstants.OUT_OF_BAND;
	}

	/**
	 * Adds an OAuth callback url
	 *
	 * @param callback callback url. Must be a valid url or 'oob' for out of
	 * band OAuth
	 * @return the {@link InstagramAuthService} instance for method chaining
	 */
	public InstagramAuthService callback(String callback) {
		Preconditions.checkValidOAuthCallback(callback, "Callback must be a valid URL or 'oob'");

		this.callback = callback;

		return this;
	}

	/**
	 * Configures the api key
	 *
	 * @param apiKey The api key for your application
	 * @return the {@link InstagramAuthService} instance for method chaining
	 */
	public InstagramAuthService apiKey(String apiKey) {
		Preconditions.checkEmptyString(apiKey, "Invalid Api key");

		this.apiKey = apiKey;

		return this;
	}

	/**
	 * Configures the api secret
	 *
	 * @param apiSecret The api secret for your application
	 * @return the {@link InstagramAuthService} instance for method chaining
	 */
	public InstagramAuthService apiSecret(String apiSecret) {
		Preconditions.checkEmptyString(apiSecret, "Invalid Api secret");

		this.apiSecret = apiSecret;

		return this;
	}

	/**
	 * Configures the OAuth scope. This is only necessary in some APIs (like
	 * Google's).
	 *
	 * @param scope The OAuth scope
	 * @return the {@link InstagramAuthService} instance for method chaining
	 */
	public InstagramAuthService scope(String scope) {
		Preconditions.checkEmptyString(scope, "Invalid OAuth scope");

		this.scope = scope;

		return this;
	}

	/**
	 * Configures the Display parameter ; if you want a mobile-optimized
	 * authorization screen
	 *
	 * @param display The Display parameter for mobile devices
	 * @return the {@link InstagramAuthService} instance for method chaining
	 */
	public InstagramAuthService display(String display) {
		Preconditions.checkEmptyString(display, "Invalid Display scope");

		this.display = display;

		return this;
	}

	/**
	 * Configures the Display parameter ; if you want a mobile-optimized
	 * authorization screen
	 *
	 * @param requestProxy The {@link Proxy} object to be used by the OAuthRequest
	 * @return the {@link InstagramAuthService} instance for method chaining
	 */
	public InstagramAuthService proxy(Proxy requestProxy) {
		//TODO Add Preconditions check here

		this.requestProxy = requestProxy;

		return this;
	}

	/**
	 * Returns the fully configured {@link OAuthService}
	 *
	 * @return fully configured {@link OAuthService}
	 */
	public InstagramService build() {
		InstagramApi api = new InstagramApi();
		OAuthConfig config;

		Preconditions.checkEmptyString(apiKey, "You must provide an api key");
		Preconditions.checkEmptyString(apiSecret, "You must provide an api secret");

		config = new OAuthConfig(apiKey, apiSecret, callback, scope, display);

		if (this.requestProxy != null) {
			config.setRequestProxy(this.requestProxy);
		}

		return api.createService(config);
	}
}


package org.jinstagram.auth.oauth;

import java.io.IOException;

import org.jinstagram.Instagram;
import org.jinstagram.InstagramClient;
import org.jinstagram.auth.InstagramApi;
import org.jinstagram.auth.exceptions.OAuthException;
import org.jinstagram.auth.model.OAuthConfig;
import org.jinstagram.auth.model.OAuthConstants;
import org.jinstagram.auth.model.OAuthRequest;
import org.jinstagram.auth.model.Token;
import org.jinstagram.auth.model.Verifier;
import org.jinstagram.http.Response;

public class InstagramService {
	private static final String VERSION = "1.0";

	private static final String AUTHORIZATION_CODE = "authorization_code";

	private final InstagramApi api;

	private final OAuthConfig config;

	/**
	 * Default constructor
	 *
	 * @param api OAuth2.0 api information
	 * @param config OAuth 2.0 configuration param object
	 */
	public InstagramService(InstagramApi api, OAuthConfig config) {
		this.api = api;
		this.config = config;
	}

	/**
	 * {@inheritDoc}
	 */
	public Token getAccessToken(Verifier verifier) {
		OAuthRequest request = new OAuthRequest(api.getAccessTokenVerb(), api.getAccessTokenEndpoint());

		// Add the oauth parameter in the body
		request.addBodyParameter(OAuthConstants.CLIENT_ID, config.getApiKey());
		request.addBodyParameter(OAuthConstants.CLIENT_SECRET, config.getApiSecret());
		request.addBodyParameter(OAuthConstants.GRANT_TYPE, AUTHORIZATION_CODE);
		request.addBodyParameter(OAuthConstants.CODE, verifier.getValue());
		request.addBodyParameter(OAuthConstants.REDIRECT_URI, config.getCallback());

		if (config.hasScope()) {
			request.addBodyParameter(OAuthConstants.SCOPE, config.getScope());
		}

		if (config.getDisplay() != null) {
			request.addBodyParameter(OAuthConstants.DISPLAY, config.getDisplay());
		}

		if (config.getRequestProxy() != null) {
			request.setProxy(config.getRequestProxy() );
		}

		Response response;
		try {
			response = request.send();
		} catch (IOException e) {
			throw new OAuthException("Could not get access token", e);
		}

		return api.getAccessTokenExtractor().extract(response.getBody());
	}

	/**
	 * {@inheritDoc}
	 */
	public Token getRequestToken() {
		throw new UnsupportedOperationException(
				"Unsupported operation, please use 'getAuthorizationUrl' and redirect your users there");
	}

	/**
	 * {@inheritDoc}
	 */
	public String getVersion() {
		return VERSION;
	}

	/**
	 * {@inheritDoc}
	 */
	public void signRequest(Token accessToken, OAuthRequest request) {
		request.addQuerystringParameter(OAuthConstants.ACCESS_TOKEN, accessToken.getToken());
	}

	/**
	 * {@inheritDoc}
	 */
	public String getAuthorizationUrl() {
		return api.getAuthorizationUrl(config);
	}

	/**
	 * Return an Instagram object
	 */
	public InstagramClient getInstagram(Token accessToken) {
		return new Instagram(accessToken);
	}

	/**
	 * Return an Instagram object with enforced signed header
	 */
	@Deprecated
	public InstagramClient getSignedHeaderInstagram(Token accessToken, String ipAddress) {
		return new Instagram(accessToken.getToken(), config.getApiSecret(), ipAddress);
	}
}


package org.jinstagram;

import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonSyntaxException;
import org.apache.commons.lang3.StringUtils;
import org.jinstagram.entity.comments.MediaCommentResponse;
import org.jinstagram.entity.comments.MediaCommentsFeed;
import org.jinstagram.entity.common.InstagramErrorResponse;
import org.jinstagram.entity.common.Pagination;
import org.jinstagram.entity.likes.LikesFeed;
import org.jinstagram.entity.locations.LocationInfo;
import org.jinstagram.entity.locations.LocationSearchFeed;
import org.jinstagram.entity.media.MediaInfoFeed;
import org.jinstagram.entity.relationships.RelationshipFeed;
import org.jinstagram.entity.tags.TagInfoFeed;
import org.jinstagram.entity.tags.TagMediaFeed;
import org.jinstagram.entity.tags.TagSearchFeed;
import org.jinstagram.entity.users.basicinfo.UserInfo;
import org.jinstagram.entity.users.feed.MediaFeed;
import org.jinstagram.entity.users.feed.UserFeed;
import org.jinstagram.exceptions.InstagramException;
import org.jinstagram.http.Request;
import org.jinstagram.http.Response;
import org.jinstagram.http.URLUtils;
import org.jinstagram.http.Verbs;
import org.jinstagram.model.Methods;
import org.jinstagram.model.QueryParam;
import org.jinstagram.model.Relationship;
import org.jinstagram.utils.LogHelper;
import org.jinstagram.utils.PaginationHelper;
import org.jinstagram.utils.Preconditions;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.net.Proxy;
import java.net.SocketException;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static java.lang.String.format;

/**
 * Instagram base class that performs no authentication
 */
public abstract class InstagramBase implements InstagramClient {

    protected static final String USER_ID_CANNOT_BE_NULL_OR_EMPTY = "UserId cannot be null or empty.";
    protected static final Logger logger = LoggerFactory.getLogger(InstagramBase.class);

    protected final InstagramConfig config;
    private Proxy requestProxy;

    public InstagramBase() {
        this(new InstagramConfig());
    }

    public InstagramBase(InstagramConfig config) {
        Preconditions.checkNotNull(config, "config cannot be null");
        this.config = config;
    }

    /**
     * @param requestProxy
     *            the proxy to set
     */
    public void setRequestProxy(Proxy requestProxy) {
        this.requestProxy = requestProxy;
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserInfo(java.lang.String)
     */
    @Override
    public UserInfo getUserInfo(String userId) throws InstagramException {

        logger.info("Getting user info for : " + userId);

        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);

        String apiMethod = format(Methods.USERS_WITH_ID, userId);

        return createInstagramObject(Verbs.GET, UserInfo.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getCurrentUserInfo()
     */
    @Override
    public UserInfo getCurrentUserInfo() throws InstagramException {
        LogHelper.logEntrance(logger, "getCurrentUserInfo", null);

        logger.info("Getting current user info...");

        return createInstagramObject(Verbs.GET, UserInfo.class, Methods.USERS_SELF, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFeeds()
     */
    @Override
    @Deprecated
    public MediaFeed getUserFeeds() throws InstagramException {
        LogHelper.logEntrance(logger, "getUserFeeds", null);

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.USERS_SELF_FEED, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserRecentMedia()
     */
    @Override
    public MediaFeed getUserRecentMedia() throws InstagramException {
        LogHelper.logEntrance(logger, "getUserRecentMedia", null);
        logger.info("Getting current user recent media...");

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.USERS_SELF_RECENT_MEDIA, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserRecentMedia(int, java.lang.String, java.lang.String)
     */
    @Override
    public MediaFeed getUserRecentMedia(int count, String minId, String maxId) throws InstagramException {
        LogHelper.logEntrance(logger, "getUserRecentMedia",
                "[ count : " + count + ", minId : " + minId + ", maxId : " + maxId + "]");
        logger.info("Getting current user recent media...");

        Map<String, String> params = new HashMap<String, String>();

        if (maxId != null) {
            params.put(QueryParam.MAX_ID, String.valueOf(maxId));
        }

        if (minId != null) {
            params.put(QueryParam.MIN_ID, String.valueOf(minId));
        }

        if (count != 0) {
            params.put(QueryParam.COUNT, String.valueOf(count));
        }

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.USERS_SELF_RECENT_MEDIA, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFeeds(java.lang.String, java.lang.String, long)
     */
    @Override
    @Deprecated
    public MediaFeed getUserFeeds(String maxId, String minId, long count) throws InstagramException {
        LogHelper.logEntrance(logger, "getUserFeeds",
                "[ maxId : " + maxId + ", minId : " + minId + ", count : " + count + "]");

        Map<String, String> params = new HashMap<String, String>();

        if (maxId != null) {
            params.put(QueryParam.MAX_ID, String.valueOf(maxId));
        }

        if (minId != null) {
            params.put(QueryParam.MIN_ID, String.valueOf(minId));
        }

        if (count != 0) {
            params.put(QueryParam.COUNT, String.valueOf(count));
        }

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.USERS_SELF_FEED, params);

    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaFeed(java.lang.String)
     */
    @Override
    public MediaFeed getRecentMediaFeed(String userId) throws InstagramException {
        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);

        String methodName = format(Methods.USERS_RECENT_MEDIA, userId);

        return createInstagramObject(Verbs.GET, MediaFeed.class, methodName, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaFeed(java.lang.String, int, java.lang.String, java.lang.String, java.util.Date, java.util.Date)
     */
    @Override
    public MediaFeed getRecentMediaFeed(String userId, int count, String minId, String maxId, Date maxTimeStamp,
            Date minTimeStamp) throws InstagramException {
        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);
        Map<String, String> params = new HashMap<String, String>();

        if (maxId != null) {
            params.put(QueryParam.MAX_ID, String.valueOf(maxId));
        }

        if (minId != null) {
            params.put(QueryParam.MIN_ID, String.valueOf(minId));
        }

        if (count != 0) {
            params.put(QueryParam.COUNT, String.valueOf(count));
        }

        if (maxTimeStamp != null) {
            params.put(QueryParam.MAX_TIMESTAMP, String.valueOf(maxTimeStamp.getTime() / 1000));
        }

        if (minTimeStamp != null) {
            params.put(QueryParam.MIN_TIMESTAMP, String.valueOf(minTimeStamp.getTime() / 1000));
        }

        String methodName = format(Methods.USERS_RECENT_MEDIA, userId);

        return createInstagramObject(Verbs.GET, MediaFeed.class, methodName, params);

    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaNextPage(org.jinstagram.entity.common.Pagination)
     */
    @Override
    public MediaFeed getRecentMediaNextPage(Pagination pagination) throws InstagramException {
        PaginationHelper.Page page = PaginationHelper.parseNextUrl(pagination, config.getApiURL());
        return createInstagramObject(Verbs.GET, MediaFeed.class,
                page.getMethodName(), page.getRawMethodName(), page.getQueryStringParams());
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFeedInfoNextPage(org.jinstagram.entity.common.Pagination)
     */
    @Override
    public UserFeed getUserFeedInfoNextPage(Pagination pagination) throws InstagramException {
        PaginationHelper.Page page = PaginationHelper.parseNextUrl(pagination, config.getApiURL());
        return createInstagramObject(Verbs.GET, UserFeed.class,
                page.getMethodName(), page.getQueryStringParams());
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getTagMediaInfoNextPage(org.jinstagram.entity.common.Pagination)
     */
    @Override
    public TagMediaFeed getTagMediaInfoNextPage(Pagination pagination) throws InstagramException {
        PaginationHelper.Page page = PaginationHelper.parseNextUrl(pagination,config.getApiURL());
        return createInstagramObject(Verbs.GET, TagMediaFeed.class,
                page.getMethodName(), page.getRawMethodName(), page.getQueryStringParams());
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserLikedMediaFeed()
     */
    @Override
    public MediaFeed getUserLikedMediaFeed() throws InstagramException {

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.USERS_SELF_LIKED_MEDIA, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserLikedMediaFeed(long, int)
     */
    @Override
    public MediaFeed getUserLikedMediaFeed(long maxLikeId, int count) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        if (maxLikeId > 0) {
            params.put(QueryParam.MAX_LIKE_ID, String.valueOf(maxLikeId));
        }

        if (count > 0) {
            params.put(QueryParam.COUNT, String.valueOf(count));
        }

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.USERS_SELF_LIKED_MEDIA, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchUser(java.lang.String)
     */
    @Override
    public UserFeed searchUser(String query) throws InstagramException {
        Preconditions.checkNotNull(query, "search query cannot be null.");

        Map<String, String> params = new HashMap<String, String>();
        params.put(QueryParam.SEARCH_QUERY, query);

        return createInstagramObject(Verbs.GET, UserFeed.class, Methods.USERS_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchUser(java.lang.String, int)
     */
    @Override
    public UserFeed searchUser(String query, int count) throws InstagramException {
        Preconditions.checkNotNull(query, "search query cannot be null.");

        Map<String, String> params = new HashMap<String, String>();
        params.put(QueryParam.SEARCH_QUERY, query);

        if (count > 0) {
            params.put(QueryParam.COUNT, String.valueOf(count));
        }

        return createInstagramObject(Verbs.GET, UserFeed.class, Methods.USERS_SEARCH, params);

    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFollowList(java.lang.String)
     */
    @Override
    public UserFeed getUserFollowList(String userId) throws InstagramException {
        return getUserFollowListNextPage(userId, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFollowListNextPage(java.lang.String, java.lang.String)
     */
    @Override
    public UserFeed getUserFollowListNextPage(String userId, String cursor) throws InstagramException {
        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);

        Map<String, String> params = new HashMap<String, String>(1);
        if (cursor != null)
            params.put("cursor", cursor);

        String apiMethod = format(Methods.USERS_ID_FOLLOWS, userId);
        return createInstagramObject(Verbs.GET, UserFeed.class, apiMethod, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFollowListNextPage(org.jinstagram.entity.common.Pagination)
     */
    @Override
    public UserFeed getUserFollowListNextPage(Pagination pagination) throws InstagramException {
        return getUserFeedInfoNextPage(pagination);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFollowedByList(java.lang.String)
     */
    @Override
    public UserFeed getUserFollowedByList(String userId) throws InstagramException {
        return getUserFollowedByListNextPage(userId, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFollowedByListNextPage(java.lang.String, java.lang.String)
     */
    @Override
    public UserFeed getUserFollowedByListNextPage(String userId, String cursor) throws InstagramException {
        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);

        Map<String, String> params = new HashMap<String, String>(1);
        if (cursor != null)
            params.put("cursor", cursor);

        String apiMethod = format(Methods.USERS_ID_FOLLOWED_BY, userId);
        return createInstagramObject(Verbs.GET, UserFeed.class, apiMethod, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserFollowedByListNextPage(org.jinstagram.entity.common.Pagination)
     */
    @Override
    public UserFeed getUserFollowedByListNextPage(Pagination pagination) throws InstagramException {
        return getUserFeedInfoNextPage(pagination);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserRequestedBy()
     */
    @Override
    public UserFeed getUserRequestedBy() throws InstagramException {
        return createInstagramObject(Verbs.GET, UserFeed.class, Methods.USERS_SELF_REQUESTED_BY, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserRelationship(java.lang.String)
     */
    @Override
    public RelationshipFeed getUserRelationship(String userId) throws InstagramException {
        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);

        String apiMethod = format(Methods.USERS_ID_RELATIONSHIP, userId);
        return createInstagramObject(Verbs.GET, RelationshipFeed.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#setUserRelationship(java.lang.String, org.jinstagram.model.Relationship)
     */
    @Override
    public RelationshipFeed setUserRelationship(String userId, Relationship relationship) throws InstagramException {
        Preconditions.checkEmptyString(userId, USER_ID_CANNOT_BE_NULL_OR_EMPTY);
        Preconditions.checkNotNull(relationship, "relationship cannot be null.");

        String apiMethod = format(Methods.USERS_ID_RELATIONSHIP, userId);
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.ACTION, relationship.toString());

        return createInstagramObject(Verbs.POST, RelationshipFeed.class, apiMethod, params);

    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getMediaInfo(java.lang.String)
     */
    @Override
    public MediaInfoFeed getMediaInfo(String mediaId) throws InstagramException {
        Preconditions.checkNotNull(mediaId, "mediaId cannot be null.");

        String apiMethod = format(Methods.MEDIA_BY_ID, mediaId);

        return createInstagramObject(Verbs.GET, MediaInfoFeed.class, apiMethod, null);

    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getMediaInfoByShortcode(java.lang.String)
     */
    @Override
    public MediaInfoFeed getMediaInfoByShortcode(String shortcode) throws InstagramException {
        Preconditions.checkNotNull(shortcode, "shortcode cannot be null.");

        String apiMethod = format(Methods.MEDIA_BY_SHORTCODE, shortcode);

        return createInstagramObject(Verbs.GET, MediaInfoFeed.class, apiMethod, null);

    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchMedia(double, double)
     */
    @Override
    public MediaFeed searchMedia(double latitude, double longitude) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.LATITUDE, Double.toString(latitude));
        params.put(QueryParam.LONGITUDE, Double.toString(longitude));

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.MEDIA_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchMedia(double, double, int)
     */
    @Override
    public MediaFeed searchMedia(double latitude, double longitude,  int distance)
            throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.LATITUDE, Double.toString(latitude));
        params.put(QueryParam.LONGITUDE, Double.toString(longitude));

        params.put(QueryParam.DISTANCE, String.valueOf(distance));

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.MEDIA_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchMedia(double, double, java.util.Date, java.util.Date, int)
     */
    @Override
    public MediaFeed searchMedia(double latitude, double longitude, Date maxTimeStamp, Date minTimeStamp, int distance)
            throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.LATITUDE, Double.toString(latitude));
        params.put(QueryParam.LONGITUDE, Double.toString(longitude));

        if (maxTimeStamp != null) {
            params.put(QueryParam.MAX_TIMESTAMP, String.valueOf(maxTimeStamp.getTime() / 1000));
        }

        if (minTimeStamp != null) {
            params.put(QueryParam.MIN_TIMESTAMP, String.valueOf(minTimeStamp.getTime() / 1000));
        }

        params.put(QueryParam.DISTANCE, String.valueOf(distance));

        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.MEDIA_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getPopularMedia()
     */
    @Override
    @Deprecated
    public MediaFeed getPopularMedia() throws InstagramException {
        return createInstagramObject(Verbs.GET, MediaFeed.class, Methods.MEDIA_POPULAR, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getMediaComments(java.lang.String)
     */
    @Override
    public MediaCommentsFeed getMediaComments(String mediaId) throws InstagramException {
        String apiMethod = format(Methods.MEDIA_COMMENTS, mediaId);

        return createInstagramObject(Verbs.GET, MediaCommentsFeed.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#setMediaComments(java.lang.String, java.lang.String)
     */
    @Override
    public MediaCommentResponse setMediaComments(String mediaId, String text) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.TEXT, text);

        String apiMethod = format(Methods.MEDIA_COMMENTS, mediaId);
        return createInstagramObject(Verbs.POST, MediaCommentResponse.class, apiMethod, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#deleteMediaCommentById(java.lang.String, java.lang.String)
     */
    @Override
    public MediaCommentResponse deleteMediaCommentById(String mediaId, String commentId) throws InstagramException {
        String apiMethod = format(Methods.DELETE_MEDIA_COMMENTS, mediaId, commentId);
        return createInstagramObject(Verbs.DELETE, MediaCommentResponse.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getUserLikes(java.lang.String)
     */
    @Override
    public LikesFeed getUserLikes(String mediaId) throws InstagramException {
        String apiMethod = format(Methods.LIKES_BY_MEDIA_ID, mediaId);

        return createInstagramObject(Verbs.GET, LikesFeed.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#setUserLike(java.lang.String)
     */
    @Override
    public LikesFeed setUserLike(String mediaId) throws InstagramException {
        String apiMethod = format(Methods.LIKES_BY_MEDIA_ID, mediaId);
        return createInstagramObject(Verbs.POST, LikesFeed.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#deleteUserLike(java.lang.String)
     */
    @Override
    public LikesFeed deleteUserLike(String mediaId) throws InstagramException {
        String apiMethod = format(Methods.LIKES_BY_MEDIA_ID, mediaId);

        return createInstagramObject(Verbs.DELETE, LikesFeed.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getTagInfo(java.lang.String)
     */
    @Override
    public TagInfoFeed getTagInfo(String tagName) throws InstagramException {
        String apiMethod = format(Methods.TAGS_BY_NAME, URLUtils.encodeURIComponent(tagName));
        String rawApiMethod = format(Methods.TAGS_BY_NAME, tagName);
        return createInstagramObject(Verbs.GET, TagInfoFeed.class, apiMethod, rawApiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaTags(java.lang.String)
     */
    @Override
    public TagMediaFeed getRecentMediaTags(String tagName) throws InstagramException {
        return getRecentMediaTags(tagName, 0);
    }

	/* (non-Javadoc)
	 * @see org.jinstagram.InstagramClient#getRecentMediaFeedTags(java.lang.String)
	 */
	@Override
	public MediaFeed getRecentMediaFeedTags(String tagName) throws InstagramException {
		return getRecentMediaFeedTags(tagName, 0);
	}

	/* (non-Javadoc)
		 * @see org.jinstagram.InstagramClient#getRecentMediaTags(java.lang.String, long)
		 */
    @Override
    public TagMediaFeed getRecentMediaTags(String tagName, long count) throws InstagramException {
        return getRecentMediaTags(tagName, null, null, count);
    }

	/* (non-Javadoc)
	 * @see org.jinstagram.InstagramClient#getRecentMediaFeedTags(java.lang.String, long)
	 */
	@Override
	public MediaFeed getRecentMediaFeedTags(String tagName, long count) throws InstagramException {
		return getRecentMediaFeedTags(tagName, null, null, count);
	}

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaTags(java.lang.String, java.lang.String, java.lang.String)
     */
    @Override
    public TagMediaFeed getRecentMediaTags(String tagName, String minTagId, String maxTagId) throws InstagramException {
        return getRecentMediaTags(tagName, minTagId, maxTagId, 0);
    }

	/* (non-Javadoc)
	 * @see org.jinstagram.InstagramClient#getRecentMediaFeedTags(java.lang.String, java.lang.String, java.lang.String)
	 */
	@Override
	public MediaFeed getRecentMediaFeedTags(String tagName, String minTagId, String maxTagId) throws InstagramException {
		return getRecentMediaFeedTags(tagName, minTagId, maxTagId, 0);
	}

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaTags(java.lang.String, java.lang.String, java.lang.String, long)
     */
    @Override
    public TagMediaFeed getRecentMediaTags(String tagName, String minTagId, String maxTagId, long count)
            throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        if (!StringUtils.isEmpty(minTagId))
            params.put(QueryParam.MIN_TAG_ID, String.valueOf(minTagId));

        if (!StringUtils.isEmpty(maxTagId))
            params.put(QueryParam.MAX_TAG_ID, String.valueOf(maxTagId));

        if (count != 0) {
            params.put(QueryParam.COUNT, String.valueOf(count));
        }

        String apiMethod = format(Methods.TAGS_RECENT_MEDIA, URLUtils.encodeURIComponent(tagName));
        String rawApiMethod = format(Methods.TAGS_RECENT_MEDIA, tagName);

        return createInstagramObject(Verbs.GET, TagMediaFeed.class, apiMethod, rawApiMethod, params);
    }

	/* (non-Javadoc)
	 * @see org.jinstagram.InstagramClient#getRecentMediaFeedTags(java.lang.String, java.lang.String, java.lang.String, long)
	 */
	@Override
	public MediaFeed getRecentMediaFeedTags(String tagName, String minTagId, String maxTagId, long count)
			throws InstagramException {
		Map<String, String> params = new HashMap<String, String>();

		if (!StringUtils.isEmpty(minTagId))
			params.put(QueryParam.MIN_TAG_ID, String.valueOf(minTagId));

		if (!StringUtils.isEmpty(maxTagId))
			params.put(QueryParam.MAX_TAG_ID, String.valueOf(maxTagId));

		if (count != 0) {
			params.put(QueryParam.COUNT, String.valueOf(count));
		}

		String apiMethod = format(Methods.TAGS_RECENT_MEDIA, URLUtils.encodeURIComponent(tagName));
		String rawApiMethod = format(Methods.TAGS_RECENT_MEDIA, tagName);

		return createInstagramObject(Verbs.GET, MediaFeed.class, apiMethod, rawApiMethod, params);
	}

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaTagsByRegularIds(java.lang.String, java.lang.String, java.lang.String)
     */
    @Override
    @Deprecated
    public TagMediaFeed getRecentMediaTagsByRegularIds(String tagName, String minId, String maxId)
            throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        if (!StringUtils.isEmpty(minId))
            params.put(QueryParam.MIN_ID, String.valueOf(minId));

        if (!StringUtils.isEmpty(maxId))
            params.put(QueryParam.MAX_ID, String.valueOf(maxId));

        String apiMethod = format(Methods.TAGS_RECENT_MEDIA, URLUtils.encodeURIComponent(tagName));
        String rawApiMethod = format(Methods.TAGS_RECENT_MEDIA, tagName);
        return createInstagramObject(Verbs.GET, TagMediaFeed.class, apiMethod, rawApiMethod, params);
    }

	/* (non-Javadoc)
	 * @see org.jinstagram.InstagramClient#getRecentMediaFeedTagsByRegularIds(java.lang.String, java.lang.String, java.lang.String)
	 */
	@Override
	public MediaFeed getRecentMediaFeedTagsByRegularIds(String tagName, String minId, String maxId)
			throws InstagramException {
		Map<String, String> params = new HashMap<String, String>();

		if (!StringUtils.isEmpty(minId))
			params.put(QueryParam.MIN_ID, String.valueOf(minId));

		if (!StringUtils.isEmpty(maxId))
			params.put(QueryParam.MAX_ID, String.valueOf(maxId));

		String apiMethod = format(Methods.TAGS_RECENT_MEDIA, URLUtils.encodeURIComponent(tagName));
		String rawApiMethod = format(Methods.TAGS_RECENT_MEDIA, tagName);
		return createInstagramObject(Verbs.GET, MediaFeed.class, apiMethod, rawApiMethod, params);
	}

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchTags(java.lang.String)
     */
    @Override
    public TagSearchFeed searchTags(String tagName) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.SEARCH_QUERY, tagName);

        return createInstagramObject(Verbs.GET, TagSearchFeed.class, Methods.TAGS_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getLocationInfo(java.lang.String)
     */
    @Override
    public LocationInfo getLocationInfo(String locationId) throws InstagramException {
        String apiMethod = format(Methods.LOCATIONS_BY_ID, locationId);

        return createInstagramObject(Verbs.GET, LocationInfo.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaByLocation(java.lang.String)
     */
    @Override
    public MediaFeed getRecentMediaByLocation(String locationId) throws InstagramException {
        String apiMethod = format(Methods.LOCATIONS_RECENT_MEDIA_BY_ID, locationId);

        return createInstagramObject(Verbs.GET, MediaFeed.class, apiMethod, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaByLocation(java.lang.String, java.lang.String, java.lang.String)
     */
    @Override
    public MediaFeed getRecentMediaByLocation(String locationId, String minId, String maxId) throws InstagramException {
    	return getRecentMediaByLocation(locationId, minId, maxId, null, null);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#getRecentMediaByLocation(java.lang.String, java.lang.String, java.lang.String, java.util.Date, java.util.Date)
     */
    @Override
    @Deprecated
    public MediaFeed getRecentMediaByLocation(String locationId, String minId, String maxId, Date maxTimeStamp,
            Date minTimeStamp) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        if (maxTimeStamp != null) {
            params.put(QueryParam.MAX_TIMESTAMP, String.valueOf(maxTimeStamp.getTime() / 1000));
        }

        if (minTimeStamp != null) {
            params.put(QueryParam.MIN_TIMESTAMP, String.valueOf(minTimeStamp.getTime() / 1000));
        }

        if (minId != null)
            params.put(QueryParam.MIN_ID, minId);

        if (maxId != null)
            params.put(QueryParam.MAX_ID, maxId);

        String apiMethod = format(Methods.LOCATIONS_RECENT_MEDIA_BY_ID, locationId);

        return createInstagramObject(Verbs.GET, MediaFeed.class, apiMethod, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchLocation(double, double)
     */
    @Override
    public LocationSearchFeed searchLocation(double latitude, double longitude) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.LATITUDE, Double.toString(latitude));
        params.put(QueryParam.LONGITUDE, Double.toString(longitude));

        return createInstagramObject(Verbs.GET, LocationSearchFeed.class, Methods.LOCATIONS_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchLocation(double, double, int)
     */
    @Override
    public LocationSearchFeed searchLocation(double latitude, double longitude, int distance)
            throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.LATITUDE, Double.toString(latitude));
        params.put(QueryParam.LONGITUDE, Double.toString(longitude));
        params.put(QueryParam.DISTANCE, Integer.toString(distance));

        return createInstagramObject(Verbs.GET, LocationSearchFeed.class, Methods.LOCATIONS_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchFoursquareVenue(java.lang.String)
     */
    @Override
    public LocationSearchFeed searchFoursquareVenue(String foursquareId) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.FOURSQUARE_V2_ID, foursquareId);

        return createInstagramObject(Verbs.GET, LocationSearchFeed.class, Methods.LOCATIONS_SEARCH, params);
    }

    /* (non-Javadoc)
     * @see org.jinstagram.InstagramClient#searchFacebookPlace(java.lang.String)
     */
    @Override
    public LocationSearchFeed searchFacebookPlace(String facebookPlacesId) throws InstagramException {
        Map<String, String> params = new HashMap<String, String>();

        params.put(QueryParam.FACEBOOK_PLACES_ID, facebookPlacesId);

        return createInstagramObject(Verbs.GET, LocationSearchFeed.class, Methods.LOCATIONS_SEARCH, params);
    }

    /**
     * Create a instagram object based on class-name and response.
     *
     * This method must be used when signing requests if the methodName
     * could contain characters that required URI encoding
     *
     * @param verbs
     *            HTTP State
     * @param clazz
     * @param methodName
     * @param rawMethodName
     * @param params
     * @return
     * @throws InstagramException
     */
    protected <T extends InstagramObject> T createInstagramObject(Verbs verbs, Class<T> clazz, String methodName,
            String rawMethodName, Map<String, String> params) throws InstagramException {
        Response response;
        String jsonResponseBody;
        try {
            response = getApiResponse(verbs, methodName, rawMethodName, params);

	        if (config.isRetryOnServerError() && wasResponseAnError(response)) {
		        Exception responseException = testResponseBody(response);

		        int numberOfRetries = 2;
		        int retryAttemptNumber = 0;
		        while (responseException != null && retryAttemptNumber < numberOfRetries) {
			        try { Thread.sleep(1000); } catch (InterruptedException e) { /* ignore */ }
			        // Retry request
			        logger.warn("(Retry #{}) Retrying request for {}", retryAttemptNumber, response.getURL());
			        response = getApiResponse(verbs, methodName, rawMethodName, params);
			        responseException = testResponseBody(response);
			        retryAttemptNumber++;
		        }
	        }

			jsonResponseBody = response.getBody();
            LogHelper.prettyPrintJSONResponse(logger, jsonResponseBody);
        } catch (IOException e) {
            throw new InstagramException("IOException while retrieving data", e);
        }

        Map<String, String> responseHeaders = response.getHeaders();
        if (response.getCode() >= 200 && response.getCode() < 300) {
	        T object = createObjectFromResponse(clazz, jsonResponseBody);
            object.setHeaders(responseHeaders);
            return object;
        }

        throw handleInstagramError(response.getCode(), jsonResponseBody, responseHeaders);
    }

	private Exception testResponseBody(Response response) {
		Exception capturedException = null;
		int code = response.getCode();

		try {
			// get response entity, attempt parse as JSON.
			String jsonString = response.getBody();
			new Gson().fromJson(jsonString, JsonObject.class);
		} catch (IllegalStateException e) {
			// this indicates a socket error (e.g. connection reset) when attempting
			// to read HTTP response entity, capture the latest exception to be thrown
			// at the end
			capturedException = e;
			if (e.getCause() instanceof SocketException) {
				logger.warn(
						format("Socket error with HTTP response (code %d).", code),
						e.getCause());
			} else {
				logger.warn(
						format("IllegalState exception with HTTP response (code %d).", code),
						e.getCause());
			}
		} catch (JsonSyntaxException e) {
			// HTTP response body contained malformed JSON
			logger.warn(
					format("HTTP response body contained malformed JSON (code %d).", code),
					response.getCode());
			capturedException = e;
		}

		return capturedException;
	}

	private boolean wasResponseAnError(Response response) {
		return (response.getCode() >= 200 && response.getCode() < 300) || response.getCode() >= 500;
	}

    /**
     * Create a instagram object based on class-name and response.
     *
     * If using signed requests this is only safe if there are no characters
     * requiring URI encoding in the methodName
     *
     * @param verbs
     *            HTTP State
     * @param clazz
     * @param methodName
     * @param params
     * @return
     * @throws InstagramException
     */
    protected <T extends InstagramObject> T createInstagramObject(Verbs verbs, Class<T> clazz, String methodName,
            Map<String, String> params) throws InstagramException {
        return createInstagramObject(verbs, clazz, methodName, methodName, params);
    }

    @Deprecated
    protected InstagramException handleInstagramError(Response response) throws InstagramException {
        Gson gson = new Gson();
        final InstagramErrorResponse error;
        String responseBody = response.getBody();
        try {
            if (response.getCode() == 400) {
                error = InstagramErrorResponse.parse(gson, responseBody);
                error.setHeaders(response.getHeaders());
                error.throwException();
            }
            // sending too many requests too quickly;
            // limited to 5000 requests per hour per access_token or client_id
            // overall. (according to spec)
            else if (response.getCode() == 503) {
                error = InstagramErrorResponse.parse(gson, responseBody);
                error.setHeaders(response.getHeaders());
                error.throwException();
            }
        } catch (JsonSyntaxException e) {
            throw new InstagramException("Failed to decode error response " + responseBody, e, response.getHeaders());
        }
        throw new InstagramException("Unknown error response code: " + response.getCode() + " " + responseBody,
                response.getHeaders());
    }

    protected InstagramException handleInstagramError(long responseCode, String responseBody,
            Map<String, String> responseHeaders) throws InstagramException {
        Gson gson = new Gson();
        final InstagramErrorResponse error;
        try {
            if (responseCode == 400) {
                error = InstagramErrorResponse.parse(gson, responseBody);
                error.setHeaders(responseHeaders);
                error.throwException();
            }
            // sending too many requests too quickly;
            // limited to 5000 requests per hour per access_token or client_id
            // overall. (according to spec)
            else if (responseCode == 429) {
                error = InstagramErrorResponse.parse(gson, responseBody);
                error.setHeaders(responseHeaders);
                error.throwException();
            }
        } catch (JsonSyntaxException e) {
            throw new InstagramException("Failed to decode error response " + responseBody, e, responseHeaders);
        }
        throw new InstagramException("Unknown error response code: " + responseCode + " " + responseBody,
                responseHeaders);
    }

    /**
     * Get response from Instagram.
     *
     * @param verb
     *            HTTP Verb
     * @param methodName
     *            Instagram API Method
     * @param rawMethodName
     *            Unencoded Instagram API Method
     * @param params
     *            parameters which would be sent with the request.
     * @return Response object.
     */
    protected Response getApiResponse(Verbs verb, String methodName, String rawMethodName, Map<String, String> params) throws IOException {
        Request request=request(verb, methodName, rawMethodName, params);
        logger.debug("Sending request to Instagram...");
        Response response=request.send();
        return response;
    }

    protected Request request(Verbs verb, String methodName, String rawMethodName, Map<String, String> params) throws InstagramException {
        String apiResourceUrl = config.getApiURL() + methodName;
        Request request = new Request(verb, apiResourceUrl);

        logger.debug("Creating request for Instagram -  " + request.getUrl());

        configureConnectionSettings(request, config);

        if (requestProxy != null) {
            request.setProxy(requestProxy);
        }

        // Additional parameters in url
        if (params != null) {

            params.remove(QueryParam.SIGNATURE); // needs to be recalculated last for every request

            for (Map.Entry<String, String> entry : params.entrySet()) {
                if (verb == Verbs.GET) {
                    request.addQuerystringParameter(entry.getKey(), entry.getValue());
                } else {
                    request.addBodyParameter(entry.getKey(), entry.getValue());
                }
            }
        }

        return request;
    }

    /**
     * Configure the request with the connection settings of config
     * @param request OAuthRequest object
     * @param config InstagramConfig object
     */
    public static void configureConnectionSettings(final Request request, final InstagramConfig config) {
        request.setConnectTimeout(config.getConnectionTimeoutMills(), TimeUnit.MILLISECONDS);
        request.setReadTimeout(config.getReadTimeoutMills(), TimeUnit.MILLISECONDS);

        // #51 Connection Keep Alive
        request.setConnectionKeepAlive(config.isConnectionKeepAlive());
    }

    /**
     * Creates an object from the JSON response and the class which the object
     * would be mapped to.
     *
     * @param clazz
     *            a class instance
     * @param response
     *            a JSON feed
     * @return a object of type <T>
     * @throws InstagramException
     *             if any error occurs.
     */
    public static <T> T createObjectFromResponse(Class<T> clazz, final String response) throws InstagramException {
        Gson gson = new Gson();
        T object;

        try {
	        object = gson.fromJson(response, clazz);
        } catch (Exception e) {
            throw new InstagramException("Error parsing json to object type " + clazz.getName(), e);
        }

        return object;
    }
}