package com.akdeniz.googleplaycrawler;

import java.io.IOException;
import java.io.InputStream;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

import org.apache.http.HttpEntity;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.ClientProtocolException;
import org.apache.http.client.HttpClient;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.methods.HttpUriRequest;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.conn.ClientConnectionManager;
import org.apache.http.entity.ByteArrayEntity;
import org.apache.http.impl.client.DefaultHttpClient;
import org.apache.http.impl.conn.PoolingClientConnectionManager;
import org.apache.http.impl.conn.SchemeRegistryFactory;
import org.apache.http.message.BasicNameValuePair;

import com.akdeniz.googleplaycrawler.GooglePlay.AndroidAppDeliveryData;
import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinRequest;
import com.akdeniz.googleplaycrawler.GooglePlay.AndroidCheckinResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.BrowseResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsRequest;
import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsRequest.Builder;
import com.akdeniz.googleplaycrawler.GooglePlay.BulkDetailsResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.BuyResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.DetailsResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.ListResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.ResponseWrapper;
import com.akdeniz.googleplaycrawler.GooglePlay.ReviewResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.SearchResponse;
import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigRequest;
import com.akdeniz.googleplaycrawler.GooglePlay.UploadDeviceConfigResponse;

/**
 * This class provides
 * <code>checkin, search, details, bulkDetails, browse, list and download</code>
 * capabilities. It uses <code>Apache Commons HttpClient</code> for POST and GET
 * requests.
 *
 * <p>
 * <b>XXX : DO NOT call checkin, login and download consecutively. To allow
 * server to catch up, sleep for a while before download! (5 sec will do!) Also
 * it is recommended to call checkin once and use generated android-id for
 * further operations.</b>
 * </p>
 *
 * @author akdeniz
 *
 */
public class GooglePlayAPI {

	private static final String CHECKIN_URL = "https://android.clients.google.com/checkin";
	private static final String URL_LOGIN = "https://android.clients.google.com/auth";
	private static final String C2DM_REGISTER_URL = "https://android.clients.google.com/c2dm/register2";
	private static final String FDFE_URL = "https://android.clients.google.com/fdfe/";
	private static final String LIST_URL = FDFE_URL + "list";
	private static final String BROWSE_URL = FDFE_URL + "browse";
	private static final String DETAILS_URL = FDFE_URL + "details";
	private static final String SEARCH_URL = FDFE_URL + "search";
	private static final String BULKDETAILS_URL = FDFE_URL + "bulkDetails";
	private static final String PURCHASE_URL = FDFE_URL + "purchase";
	private static final String REVIEWS_URL = FDFE_URL + "rev";
	public static final String UPLOADDEVICECONFIG_URL = FDFE_URL + "uploadDeviceConfig";
	private static final String RECOMMENDATIONS_URL = FDFE_URL + "rec";
	private static final String DELIVERY_URL = FDFE_URL + "delivery";

	private static final String ACCOUNT_TYPE_HOSTED_OR_GOOGLE = "HOSTED_OR_GOOGLE";

	public static enum REVIEW_SORT {
		NEWEST(0), HIGHRATING(1), HELPFUL(2);

		public int value;

		private REVIEW_SORT(int value) {
			this.value = value;
		}
	}

	public static enum RECOMMENDATION_TYPE {
		ALSO_VIEWED(1), ALSO_INSTALLED(2);

		public int value;

		private RECOMMENDATION_TYPE(int value) {
			this.value = value;
		}
	}

	private String token;
	private String androidID;
	private String email;
	private String password;
	private HttpClient client;
	private String securityToken;
	private String localization;

	/**
	 * Default constructor. ANDROID ID and Authentication token must be supplied
	 * before any other operation.
	 */
	public GooglePlayAPI() {
	}

	/**
	 * Constructs a ready to login {@link GooglePlayAPI}.
	 */
	public GooglePlayAPI(String email, String password, String androidID) {
		this(email, password);
		this.setAndroidID(androidID);
	}

	/**
	 * If this constructor is used, Android ID must be generated by calling
	 * <code>checkin()</code> or set by using <code>setAndroidID</code> before
	 * using other abilities.
	 */
	public GooglePlayAPI(String email, String password) {
		this.setEmail(email);
		this.password = password;
		setClient(new DefaultHttpClient(getConnectionManager()));
	}

	/**
	 * Connection manager to allow concurrent connections.
	 *
	 * @return {@link ClientConnectionManager} instance
	 */
	public static ClientConnectionManager getConnectionManager() {
		PoolingClientConnectionManager connManager = new PoolingClientConnectionManager(
				SchemeRegistryFactory.createDefault());
		connManager.setMaxTotal(100);
		connManager.setDefaultMaxPerRoute(30);
		return connManager;
	}

	/**
	 * Performs authentication on "ac2dm" service and match up android id,
	 * security token and email by checking them in on this server.
	 *
	 * This function sets check-inded android ID and that can be taken either by
	 * using <code>getToken()</code> or from returned
	 * {@link AndroidCheckinResponse} instance.
	 *
	 */
	public AndroidCheckinResponse checkin() throws Exception {

		// this first checkin is for generating android-id
		AndroidCheckinResponse checkinResponse = postCheckin(Utils.generateAndroidCheckinRequest()
				.toByteArray());
		this.setAndroidID(BigInteger.valueOf(checkinResponse.getAndroidId()).toString(16).toUpperCase());
		setSecurityToken((BigInteger.valueOf(checkinResponse.getSecurityToken()).toString(16)));

		String c2dmAuth = loginAC2DM();

		AndroidCheckinRequest.Builder checkInbuilder = AndroidCheckinRequest.newBuilder(Utils
				.generateAndroidCheckinRequest());

		AndroidCheckinRequest build = checkInbuilder
				.setId(new BigInteger(this.getAndroidID(), 16).longValue())
				.setSecurityToken(new BigInteger(getSecurityToken(), 16).longValue())
				.addAccountCookie("[" + getEmail() + "]").addAccountCookie(c2dmAuth).build();
		// this is the second checkin to match credentials with android-id
		return postCheckin(build.toByteArray());
	}

	/**
	 * Logins AC2DM server and returns authentication string.
	 */
	public String loginAC2DM() throws IOException {
		HttpEntity c2dmResponseEntity = executePost(URL_LOGIN,
				new String[][] {
						{ "Email", this.getEmail() },
						{ "Passwd", this.password },
						{ "service", "ac2dm" },
						{ "add_account", "1"},
						{ "accountType", ACCOUNT_TYPE_HOSTED_OR_GOOGLE },
						{ "has_permission", "1" },
						{ "source", "android" },
						{ "app", "com.google.android.gsf" },
						{ "device_country", "us" },
						{ "lang", "en" },
						{ "sdk_version", "17" }, }, null);

		Map<String, String> c2dmAuth = Utils.parseResponse(new String(Utils.readAll(c2dmResponseEntity
				.getContent())));
		return c2dmAuth.get("Auth");

	}

	public Map<String, String> c2dmRegister(String application, String sender) throws IOException {

		String c2dmAuth = loginAC2DM();
		String[][] data = new String[][] {
				{ "app", application },
				{ "sender", sender },
				{ "device", new BigInteger(this.getAndroidID(), 16).toString().toUpperCase() } };
		HttpEntity responseEntity = executePost(C2DM_REGISTER_URL, data,
				getHeaderParameters(c2dmAuth, null));
		return Utils.parseResponse(new String(Utils.readAll(responseEntity.getContent())));
	}

	/**
	 * Equivalent of <code>setToken</code>. This function does not performs
	 * authentication, it simply sets authentication token.
	 */
	public void login(String token) throws Exception {
		setToken(token);
	}

	/**
	 * Authenticates on server with given email and password and sets
	 * authentication token. This token can be used to login instead of using
	 * email and password every time.
	 */
	public void login() throws Exception {

		HttpEntity responseEntity = executePost(URL_LOGIN, new String[][] {
				{ "Email", this.getEmail() },
				{ "Passwd", this.password },
				{ "service", "androidmarket" },
				{ "accountType", ACCOUNT_TYPE_HOSTED_OR_GOOGLE },
				{ "has_permission", "1" },
				{ "source", "android" },
				{ "androidId", this.getAndroidID() },
				{ "app", "com.android.vending" },
				{ "device_country", "us" },
				{ "lang", "en" },
				{ "sdk_version", "17" }, }, null);

		Map<String, String> response = Utils.parseResponse(new String(Utils.readAll(responseEntity
				.getContent())));
		if (response.containsKey("Auth")) {
			setToken(response.get("Auth"));
		}
		else {
			throw new GooglePlayException("Authentication failed!");
		}
	}

	/**
	 * Equivalent of <code>search(query, null, null)</code>
	 */
	public SearchResponse search(String query) throws IOException {
		return search(query, null, null);
	}

	/**
	 * Fetches a search results for given query. Offset and numberOfResult
	 * parameters are optional and <code>null</code> can be passed!
	 */
	public SearchResponse search(String query, Integer offset, Integer numberOfResult)
			throws IOException {

		ResponseWrapper responseWrapper = executeGETRequest(SEARCH_URL, new String[][] {
				{ "c", "3" },
				{ "q", query },
				{ "o", (offset == null) ? null : String.valueOf(offset) },
				{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) }, });

		return responseWrapper.getPayload().getSearchResponse();
	}

	/**
	 * Fetches detailed information about passed package name. If it is needed to
	 * fetch information about more than one application, consider to use
	 * <code>bulkDetails</code>.
	 */
	public DetailsResponse details(String packageName) throws IOException {
		ResponseWrapper responseWrapper = executeGETRequest(DETAILS_URL, new String[][] { {
				"doc",
				packageName }, });

		return responseWrapper.getPayload().getDetailsResponse();
	}

	/** Equivalent of details but bulky one! */
	public BulkDetailsResponse bulkDetails(List<String> packageNames) throws IOException {

		Builder bulkDetailsRequestBuilder = BulkDetailsRequest.newBuilder();
		bulkDetailsRequestBuilder.addAllDocid(packageNames);

		ResponseWrapper responseWrapper = executePOSTRequest(BULKDETAILS_URL, bulkDetailsRequestBuilder
				.build().toByteArray(), "application/x-protobuf");

		return responseWrapper.getPayload().getBulkDetailsResponse();
	}

	/** Fetches available categories */
	public BrowseResponse browse() throws IOException {

		return browse(null, null);
	}

	public BrowseResponse browse(String categoryId, String subCategoryId) throws IOException {

		ResponseWrapper responseWrapper = executeGETRequest(BROWSE_URL, new String[][] {
				{ "c", "3" },
				{ "cat", categoryId },
				{ "ctr", subCategoryId } });

		return responseWrapper.getPayload().getBrowseResponse();
	}

	/**
	 * Equivalent of <code>list(categoryId, null, null, null)</code>. It fetches
	 * sub-categories of given category!
	 */
	public ListResponse list(String categoryId) throws IOException {
		return list(categoryId, null, null, null);
	}

	/**
	 * Fetches applications within supplied category and sub-category. If
	 * <code>null</code> is given for sub-category, it fetches sub-categories of
	 * passed category.
	 *
	 * Default values for offset and numberOfResult are "0" and "20" respectively.
	 * These values are determined by Google Play Store.
	 */
	public ListResponse list(String categoryId, String subCategoryId, Integer offset,
			Integer numberOfResult) throws IOException {
		ResponseWrapper responseWrapper = executeGETRequest(LIST_URL, new String[][] {
				{ "c", "3" },
				{ "cat", categoryId },
				{ "ctr", subCategoryId },
				{ "o", (offset == null) ? null : String.valueOf(offset) },
				{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) }, });

		return responseWrapper.getPayload().getListResponse();
	}

	/**
	 * Downloads given application package name, version and offer type. Version
	 * code and offer type can be fetch by <code>details</code> interface.
	 **/
	public DownloadData download(String packageName, int versionCode, int offerType)
			throws IOException {

		BuyResponse buyResponse = purchase(packageName, versionCode, offerType);

		return new DownloadData(this, buyResponse.getPurchaseStatusResponse().getAppDeliveryData());

	}

	public DownloadData delivery(String packageName, int versionCode, int offerType)
			throws IOException {
		ResponseWrapper responseWrapper = executeGETRequest(DELIVERY_URL, new String[][] {
				{ "ot", String.valueOf(offerType) },
				{ "doc", packageName },
				{ "vc", String.valueOf(versionCode) }, });

		AndroidAppDeliveryData appDeliveryData = responseWrapper.getPayload().getDeliveryResponse()
				.getAppDeliveryData();
		return new DownloadData(this, appDeliveryData);
	}

	/**
	 * Posts given check-in request content and returns
	 * {@link AndroidCheckinResponse}.
	 */
	public AndroidCheckinResponse postCheckin(byte[] request) throws IOException {

		HttpEntity httpEntity = executePost(CHECKIN_URL, new ByteArrayEntity(request), new String[][] {
				{ "User-Agent", "Android-Checkin/2.0 (generic JRO03E); gzip" },
				{ "Host", "android.clients.google.com" },
				{ "Content-Type", "application/x-protobuffer" } });
		return AndroidCheckinResponse.parseFrom(httpEntity.getContent());
	}

	/**
	 * This function is used for fetching download url and donwload cookie, rather
	 * than actual purchasing.
	 */
	private BuyResponse purchase(String packageName, int versionCode, int offerType)
			throws IOException {

		ResponseWrapper responseWrapper = executePOSTRequest(PURCHASE_URL, new String[][] {
				{ "ot", String.valueOf(offerType) },
				{ "doc", packageName },
				{ "vc", String.valueOf(versionCode) }, });

		return responseWrapper.getPayload().getBuyResponse();
	}

	/**
	 * Fetches url content by executing GET request with provided cookie string.
	 */
	public InputStream executeDownload(String url, String cookie) throws IOException {

		String[][] headerParams = new String[][] {
				{ "Cookie", cookie },
				{
						"User-Agent",
						"AndroidDownloadManager/6.0.1 (Linux; U; Android 6.0.1; Nexus 6P Build/MTC19T)" }, };

		HttpEntity httpEntity = executeGet(url, null, headerParams);
		return httpEntity.getContent();
	}

	/**
	 * Fetches the reviews of given package name by sorting passed choice.
	 *
	 * Default values for offset and numberOfResult are "0" and "20" respectively.
	 * These values are determined by Google Play Store.
	 */
	public ReviewResponse reviews(String packageName, REVIEW_SORT sort, Integer offset,
			Integer numberOfResult) throws IOException {
		ResponseWrapper responseWrapper = executeGETRequest(REVIEWS_URL, new String[][] {
				{ "doc", packageName },
				{ "sort", (sort == null) ? null : String.valueOf(sort.value) },
				{ "o", (offset == null) ? null : String.valueOf(offset) },
				{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) } });

		return responseWrapper.getPayload().getReviewResponse();
	}

	/**
	 * Uploads device configuration to google server so that can be seen from web
	 * as a registered device!!
	 *
	 * @see https://play.google.com/store/account
	 */
	public UploadDeviceConfigResponse uploadDeviceConfig() throws Exception {

		UploadDeviceConfigRequest request = UploadDeviceConfigRequest.newBuilder()
				.setDeviceConfiguration(Utils.getDeviceConfigurationProto()).build();
		ResponseWrapper responseWrapper = executePOSTRequest(UPLOADDEVICECONFIG_URL,
				request.toByteArray(), "application/x-protobuf");
		return responseWrapper.getPayload().getUploadDeviceConfigResponse();
	}

	/**
	 * Fetches the recommendations of given package name.
	 *
	 * Default values for offset and numberOfResult are "0" and "20" respectively.
	 * These values are determined by Google Play Store.
	 */
	public ListResponse recommendations(String packageName, RECOMMENDATION_TYPE type, Integer offset,
			Integer numberOfResult) throws IOException {
		ResponseWrapper responseWrapper = executeGETRequest(RECOMMENDATIONS_URL, new String[][] {
				{ "c", "3" },
				{ "doc", packageName },
				{ "rt", (type == null) ? null : String.valueOf(type.value) },
				{ "o", (offset == null) ? null : String.valueOf(offset) },
				{ "n", (numberOfResult == null) ? null : String.valueOf(numberOfResult) } });

		return responseWrapper.getPayload().getListResponse();
	}

	/* =======================Helper Functions====================== */

	/**
	 * Executes GET request and returns result as {@link ResponseWrapper}.
	 * Standard header parameters will be used for request.
	 *
	 * @see getHeaderParameters
	 * */
	private ResponseWrapper executeGETRequest(String path, String[][] datapost) throws IOException {

		HttpEntity httpEntity = executeGet(path, datapost, getHeaderParameters(this.getToken(), null));
		return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent());

	}

	/**
	 * Executes POST request and returns result as {@link ResponseWrapper}.
	 * Standard header parameters will be used for request.
	 *
	 * @see getHeaderParameters
	 * */
	private ResponseWrapper executePOSTRequest(String path, String[][] datapost) throws IOException {

		HttpEntity httpEntity = executePost(path, datapost, getHeaderParameters(this.getToken(), null));
		return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent());

	}

	/**
	 * Executes POST request and returns result as {@link ResponseWrapper}.
	 * Content type can be specified for given byte array.
	 */
	public ResponseWrapper executePOSTRequest(String url, byte[] datapost, String contentType)
			throws IOException {

		HttpEntity httpEntity = executePost(url, new ByteArrayEntity(datapost),
				getHeaderParameters(this.getToken(), contentType));
		return GooglePlay.ResponseWrapper.parseFrom(httpEntity.getContent());

	}

	/**
	 * Executes POST request on given URL with POST parameters and header
	 * parameters.
	 */
	private HttpEntity executePost(String url, String[][] postParams, String[][] headerParams)
			throws IOException {

		List<NameValuePair> formparams = new ArrayList<NameValuePair>();

		for (String[] param : postParams) {
			if (param[0] != null && param[1] != null) {
				formparams.add(new BasicNameValuePair(param[0], param[1]));
			}
		}

		UrlEncodedFormEntity entity = new UrlEncodedFormEntity(formparams, "UTF-8");

		return executePost(url, entity, headerParams);
	}

	/**
	 * Executes POST request on given URL with {@link HttpEntity} typed POST
	 * parameters and header parameters.
	 */
	private HttpEntity executePost(String url, HttpEntity postData, String[][] headerParams)
			throws IOException {
		HttpPost httppost = new HttpPost(url);

		if (headerParams != null) {
			for (String[] param : headerParams) {
				if (param[0] != null && param[1] != null) {
					httppost.setHeader(param[0], param[1]);
				}
			}
		}

		httppost.setEntity(postData);

		return executeHttpRequest(httppost);
	}

	/**
	 * Executes GET request on given URL with GET parameters and header
	 * parameters.
	 */
	private HttpEntity executeGet(String url, String[][] getParams, String[][] headerParams)
			throws IOException {

		if (getParams != null) {
			List<NameValuePair> formparams = new ArrayList<NameValuePair>();

			for (String[] param : getParams) {
				if (param[0] != null && param[1] != null) {
					formparams.add(new BasicNameValuePair(param[0], param[1]));
				}
			}

			url = url + "?" + URLEncodedUtils.format(formparams, "UTF-8");
		}

		HttpGet httpget = new HttpGet(url);

		if (headerParams != null) {
			for (String[] param : headerParams) {
				if (param[0] != null && param[1] != null) {
					httpget.setHeader(param[0], param[1]);
				}
			}
		}

		return executeHttpRequest(httpget);
	}

	/** Executes given GET/POST request */
	private HttpEntity executeHttpRequest(HttpUriRequest request) throws ClientProtocolException,
			IOException {

		HttpResponse response = getClient().execute(request);

		if (response.getStatusLine().getStatusCode() != 200) {
			throw new GooglePlayException(new String(Utils.readAll(response.getEntity().getContent())));
		}

		return response.getEntity();
	}

	/**
	 * Gets header parameters for GET/POST requests. If no content type is given,
	 * default one is used!
	 */
	private String[][] getHeaderParameters(String token, String contentType) {

		return new String[][] {
				{ "Accept-Language", getLocalization() != null ? getLocalization() : "en_US" },
				{ "Authorization", "GoogleLogin auth=" + token },
				{ "X-DFE-Enabled-Experiments", "cl:billing.select_add_instrument_by_default" },
				{
						"X-DFE-Unsupported-Experiments",
						"nocache:billing.use_charging_poller,market_emails,buyer_currency,prod_baseline,checkin.set_asset_paid_app_field,shekel_test,content_ratings,buyer_currency_in_app,nocache:encrypted_apk,recent_changes" },
				{ "X-DFE-Device-Id", this.getAndroidID() },
				{ "X-DFE-Client-Id", "am-android-google" },
				{
						"User-Agent",
						"Android-Finsky/6.7.07.E (versionCode=80670700,sdk=23,device=angler,hardware=angler,product=angler,build=MTC19T:user)" },
				{ "X-DFE-SmallestScreenWidthDp", "320" },
				{ "X-DFE-Filter-Level", "3" },
				{ "Host", "android.clients.google.com" },
				{
						"Content-Type",
						(contentType != null) ? contentType
								: "application/x-www-form-urlencoded; charset=UTF-8" } };
	}

	public String getToken() {
		return token;
	}

	public void setToken(String token) {
		this.token = token;
	}

	public String getAndroidID() {
		return androidID;
	}

	public void setAndroidID(String androidID) {
		this.androidID = androidID;
	}

	public String getSecurityToken() {
		return securityToken;
	}

	public void setSecurityToken(String securityToken) {
		this.securityToken = securityToken;
	}

	public HttpClient getClient() {
		return client;
	}

	/**
	 * Sets {@link HttpClient} instance for internal usage of GooglePlayAPI. It is
	 * important to note that this instance should allow concurrent connections.
	 *
	 * @see getConnectionManager
	 *
	 * @param client
	 */
	public void setClient(HttpClient client) {
		this.client = client;
	}

	public String getEmail() {
		return email;
	}

	public void setEmail(String email) {
		this.email = email;
	}

	public String getLocalization() {
		return localization;
	}

	/**
	 * Localization string that will be used in each request to server. Using this
	 * option you can fetch localized informations such as reviews and
	 * descriptions.
	 * <p>
	 * Note that changing this value has no affect on localized application list
	 * that server provides. It depends on only your IP location.
	 * <p>
	 *
	 * @param localization
	 *          can be <b>en-EN, en-US, tr-TR, fr-FR ... (default : en-EN)</b>
	 */
	public void setLocalization(String localization) {
		this.localization = localization;
	}

}