package com.todoroo.astrid.billing;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedList;

import android.app.PendingIntent;
import android.app.Service;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.ServiceConnection;
import android.os.Bundle;
import android.os.IBinder;
import android.os.RemoteException;
import android.util.Log;

import com.android.vending.billing.IMarketBillingService;
import com.todoroo.astrid.billing.BillingConstants.PurchaseState;
import com.todoroo.astrid.billing.BillingConstants.ResponseCode;
import com.todoroo.astrid.billing.Security.VerifiedPurchase;

@SuppressWarnings("nls")
public class BillingService extends Service implements ServiceConnection {
    private static final String TAG = "billing-service";

    /** The service connection to the remote MarketBillingService. */
    private static IMarketBillingService mService;

    /**
     * The list of requests that are pending while we are waiting for the
     * connection to the MarketBillingService to be established.
     */
    private static LinkedList<BillingRequest> mPendingRequests = new LinkedList<BillingRequest>();

    /**
     * The list of requests that we have sent to Android Market but for which we have
     * not yet received a response code. The HashMap is indexed by the
     * request Id that each request receives when it executes.
     */
    private static HashMap<Long, BillingRequest> mSentRequests =
        new HashMap<Long, BillingRequest>();

    /**
     * The base class for all requests that use the MarketBillingService.
     * Each derived class overrides the run() method to call the appropriate
     * service interface.  If we are already connected to the MarketBillingService,
     * then we call the run() method directly. Otherwise, we bind
     * to the service and save the request on a queue to be run later when
     * the service is connected.
     */
    abstract class BillingRequest {
        private final int mStartId;
        protected long mRequestId;

        public BillingRequest(int startId) {
            mStartId = startId;
        }

        public int getStartId() {
            return mStartId;
        }

        /**
         * Run the request, starting the connection if necessary.
         * @return true if the request was executed or queued; false if there
         * was an error starting the connection
         */
        public boolean runRequest() {
            if (runIfConnected()) {
                return true;
            }

            if (bindToMarketBillingService()) {
                // Add a pending request to run when the service is connected.
                mPendingRequests.add(this);
                return true;
            }
            return false;
        }

        /**
         * Try running the request directly if the service is already connected.
         * @return true if the request ran successfully; false if the service
         * is not connected or there was an error when trying to use it
         */
        public boolean runIfConnected() {
            if (BillingConstants.DEBUG) {
                Log.d(TAG, getClass().getSimpleName());
            }
            if (mService != null) {
                try {
                    mRequestId = run();
                    if (BillingConstants.DEBUG) {
                        Log.d(TAG, "request id: " + mRequestId);
                    }
                    if (mRequestId >= 0) {
                        mSentRequests.put(mRequestId, this);
                    }
                    return true;
                } catch (RemoteException e) {
                    onRemoteException(e);
                }
            }
            return false;
        }

        /**
         * Called when a remote exception occurs while trying to execute the
         * {@link #run()} method.  The derived class can override this to
         * execute exception-handling code.
         * @param e the exception
         */
        protected void onRemoteException(RemoteException e) {
            Log.w(TAG, "remote billing service crashed");
            mService = null;
        }

        /**
         * The derived class must implement this method.
         * @throws RemoteException
         */
        abstract protected long run() throws RemoteException;

        /**
         * This is called when Android Market sends a response code for this
         * request.
         * @param responseCode the response code
         */
        protected void responseCodeReceived(ResponseCode responseCode) {
            //
        }

        protected Bundle makeRequestBundle(String method) {
            Bundle request = new Bundle();
            request.putString(BillingConstants.BILLING_REQUEST_METHOD, method);
            request.putInt(BillingConstants.BILLING_REQUEST_API_VERSION, 2);
            request.putString(BillingConstants.BILLING_REQUEST_PACKAGE_NAME, getPackageName());
            return request;
        }

        protected void logResponseCode(String method, Bundle response) {
            ResponseCode responseCode = ResponseCode.valueOf(
                    response.getInt(BillingConstants.BILLING_RESPONSE_RESPONSE_CODE));
            if (BillingConstants.DEBUG) {
                Log.e(TAG, method + " received " + responseCode.toString());
            }
        }
    }

    /**
     * Wrapper class that checks if in-app billing is supported.
     *
     * Note: Support for subscriptions implies support for one-time purchases. However, the opposite
     * is not true.
     *
     * Developers may want to perform two checks if both one-time and subscription products are
     * available.
     */
    class CheckBillingSupported extends BillingRequest {
        public String mProductType = null;

        /** Constructor
         *
         * Note: Support for subscriptions implies support for one-time purchases. However, the
         * opposite is not true.
         *
         * Developers may want to perform two checks if both one-time and subscription products are
         * available.
         *
         * @pram itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating
         * the type of item support is being checked for.
         */
        public CheckBillingSupported(String itemType) {
            super(-1);
            mProductType = itemType;
        }

        @Override
        protected long run() throws RemoteException {
            Bundle request = makeRequestBundle("CHECK_BILLING_SUPPORTED");
            if (mProductType != null) {
                request.putString(BillingConstants.BILLING_REQUEST_ITEM_TYPE, mProductType);
            }
            Bundle response = mService.sendBillingRequest(request);
            int responseCode = response.getInt(BillingConstants.BILLING_RESPONSE_RESPONSE_CODE);
            if (BillingConstants.DEBUG) {
                Log.i(TAG, "CheckBillingSupported response code: " +
                        ResponseCode.valueOf(responseCode));
            }
            boolean billingSupported = (responseCode == ResponseCode.RESULT_OK.ordinal());
            ResponseHandler.checkBillingSupportedResponse(billingSupported, mProductType);
            return BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID;
        }
    }

    /**
     * Wrapper class that requests a purchase.
     */
    class RequestPurchase extends BillingRequest {
        public final String mProductId;
        public final String mDeveloperPayload;
        public final String mProductType;

        /** Constructor
         *
         * @param itemId  The ID of the item to be purchased. Will be assumed to be a one-time
         *                purchase.
         * @param itemType  Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION,
         *                  indicating the type of item type support is being checked for.
         * @param developerPayload Optional data.
         */
        public RequestPurchase(String itemId, String itemType, String developerPayload) {
            // This object is never created as a side effect of starting this
            // service so we pass -1 as the startId to indicate that we should
            // not stop this service after executing this request.
            super(-1);
            mProductId = itemId;
            mDeveloperPayload = developerPayload;
            mProductType = itemType;
        }

        @Override
        protected long run() throws RemoteException {
            Bundle request = makeRequestBundle("REQUEST_PURCHASE");
            request.putString(BillingConstants.BILLING_REQUEST_ITEM_ID, mProductId);
            request.putString(BillingConstants.BILLING_REQUEST_ITEM_TYPE, mProductType);
            // Note that the developer payload is optional.
            if (mDeveloperPayload != null) {
                request.putString(BillingConstants.BILLING_REQUEST_DEVELOPER_PAYLOAD, mDeveloperPayload);
            }
            Bundle response = mService.sendBillingRequest(request);
            PendingIntent pendingIntent
                    = response.getParcelable(BillingConstants.BILLING_RESPONSE_PURCHASE_INTENT);
            if (pendingIntent == null) {
                Log.e(TAG, "Error with requestPurchase");
                return BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID;
            }

            Intent intent = new Intent();
            ResponseHandler.buyPageIntentResponse(pendingIntent, intent);
            return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
                    BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
        }

        @Override
        protected void responseCodeReceived(ResponseCode responseCode) {
            ResponseHandler.responseCodeReceived(BillingService.this, this, responseCode);
        }
    }

    /**
     * Wrapper class that confirms a list of notifications to the server.
     */
    class ConfirmNotifications extends BillingRequest {
        final String[] mNotifyIds;

        public ConfirmNotifications(int startId, String[] notifyIds) {
            super(startId);
            mNotifyIds = notifyIds;
        }

        @Override
        protected long run() throws RemoteException {
            Bundle request = makeRequestBundle("CONFIRM_NOTIFICATIONS");
            request.putStringArray(BillingConstants.BILLING_REQUEST_NOTIFY_IDS, mNotifyIds);
            Bundle response = mService.sendBillingRequest(request);
            logResponseCode("confirmNotifications", response);
            return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
                    BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
        }
    }

    /**
     * Wrapper class that sends a GET_PURCHASE_INFORMATION message to the server.
     */
    class GetPurchaseInformation extends BillingRequest {
        long mNonce;
        final String[] mNotifyIds;

        public GetPurchaseInformation(int startId, String[] notifyIds) {
            super(startId);
            mNotifyIds = notifyIds;
        }

        @Override
        protected long run() throws RemoteException {
            mNonce = Security.generateNonce();

            Bundle request = makeRequestBundle("GET_PURCHASE_INFORMATION");
            request.putLong(BillingConstants.BILLING_REQUEST_NONCE, mNonce);
            request.putStringArray(BillingConstants.BILLING_REQUEST_NOTIFY_IDS, mNotifyIds);
            Bundle response = mService.sendBillingRequest(request);
            logResponseCode("getPurchaseInformation", response);
            return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
                    BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
        }

        @Override
        protected void onRemoteException(RemoteException e) {
            super.onRemoteException(e);
            Security.removeNonce(mNonce);
        }
    }

    /**
     * Wrapper class that sends a RESTORE_TRANSACTIONS message to the server.
     */
    class RestoreTransactions extends BillingRequest {
        long mNonce;

        public RestoreTransactions() {
            // This object is never created as a side effect of starting this
            // service so we pass -1 as the startId to indicate that we should
            // not stop this service after executing this request.
            super(-1);
        }

        @Override
        protected long run() throws RemoteException {
            mNonce = Security.generateNonce();

            Bundle request = makeRequestBundle("RESTORE_TRANSACTIONS");
            request.putLong(BillingConstants.BILLING_REQUEST_NONCE, mNonce);
            Bundle response = mService.sendBillingRequest(request);
            logResponseCode("restoreTransactions", response);
            return response.getLong(BillingConstants.BILLING_RESPONSE_REQUEST_ID,
                    BillingConstants.BILLING_RESPONSE_INVALID_REQUEST_ID);
        }

        @Override
        protected void onRemoteException(RemoteException e) {
            super.onRemoteException(e);
            Security.removeNonce(mNonce);
        }

        @Override
        protected void responseCodeReceived(ResponseCode responseCode) {
            ResponseHandler.responseCodeReceived(BillingService.this, this, responseCode);
        }
    }

    public BillingService() {
        super();
    }

    public void setContext(Context context) {
        attachBaseContext(context);
    }

    /**
     * We don't support binding to this service, only starting the service.
     */
    @Override
    public IBinder onBind(Intent intent) {
        return null;
    }

    @Override
    public void onStart(Intent intent, int startId) {
        handleCommand(intent, startId);
    }

    /**
     * The {@link BillingReceiver} sends messages to this service using intents.
     * Each intent has an action and some extra arguments specific to that action.
     * @param intent the intent containing one of the supported actions
     * @param startId an identifier for the invocation instance of this service
     */
    public void handleCommand(Intent intent, int startId) {
        if (intent == null)
            return;
        String action = intent.getAction();
        if (BillingConstants.DEBUG) {
            Log.i(TAG, "handleCommand() action: " + action);
        }
        if (BillingConstants.ACTION_CONFIRM_NOTIFICATION.equals(action)) {
            String[] notifyIds = intent.getStringArrayExtra(BillingConstants.NOTIFICATION_ID);
            confirmNotifications(startId, notifyIds);
        } else if (BillingConstants.ACTION_GET_PURCHASE_INFORMATION.equals(action)) {
            String notifyId = intent.getStringExtra(BillingConstants.NOTIFICATION_ID);
            getPurchaseInformation(startId, new String[] { notifyId });
        } else if (BillingConstants.ACTION_PURCHASE_STATE_CHANGED.equals(action)) {
            String signedData = intent.getStringExtra(BillingConstants.INAPP_SIGNED_DATA);
            String signature = intent.getStringExtra(BillingConstants.INAPP_SIGNATURE);
            purchaseStateChanged(startId, signedData, signature);
        } else if (BillingConstants.ACTION_RESPONSE_CODE.equals(action)) {
            long requestId = intent.getLongExtra(BillingConstants.INAPP_REQUEST_ID, -1);
            int responseCodeIndex = intent.getIntExtra(BillingConstants.INAPP_RESPONSE_CODE,
                    ResponseCode.RESULT_ERROR.ordinal());
            ResponseCode responseCode = ResponseCode.valueOf(responseCodeIndex);
            checkResponseCode(requestId, responseCode);
        }
    }

    /**
     * Binds to the MarketBillingService and returns true if the bind
     * succeeded.
     * @return true if the bind succeeded; false otherwise
     */
    private boolean bindToMarketBillingService() {
        try {
            if (BillingConstants.DEBUG) {
                Log.i(TAG, "binding to Market billing service");
            }
            boolean bindResult = bindService(
                    new Intent(BillingConstants.MARKET_BILLING_SERVICE_ACTION),
                    this,  // ServiceConnection.
                    Context.BIND_AUTO_CREATE);

            if (bindResult) {
                return true;
            } else {
                Log.e(TAG, "Could not bind to service.");
            }
        } catch (SecurityException e) {
            Log.e(TAG, "Security exception: " + e);
        }
        return false;
    }

    /**
     * Checks if in-app billing is supported.
     * @pram itemType Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating the
     *                type of item support is being checked for.
     * @return true if supported; false otherwise
     */
    public boolean checkBillingSupported(String itemType) {
        return new CheckBillingSupported(itemType).runRequest();
    }

    /**
     * Requests that the given item be offered to the user for purchase. When
     * the purchase succeeds (or is canceled) the {@link BillingReceiver}
     * receives an intent with the action {@link BillingConstants#ACTION_NOTIFY}.
     * Returns false if there was an error trying to connect to Android Market.
     * @param productId an identifier for the item being offered for purchase
     * @param itemType  Either BillingConstants.ITEM_TYPE_INAPP or BillingConstants.ITEM_TYPE_SUBSCRIPTION, indicating
     *                  the type of item type support is being checked for.
     * @param developerPayload a payload that is associated with a given
     * purchase, if null, no payload is sent
     * @return false if there was an error connecting to Android Market
     */
    public boolean requestPurchase(String productId, String itemType, String developerPayload) {
        return new RequestPurchase(productId, itemType, developerPayload).runRequest();
    }

    /**
     * Requests transaction information for all managed items. Call this only when the
     * application is first installed or after a database wipe. Do NOT call this
     * every time the application starts up.
     * @return false if there was an error connecting to Android Market
     */
    public boolean restoreTransactions() {
        return new RestoreTransactions().runRequest();
    }

    /**
     * Confirms receipt of a purchase state change. Each {@code notifyId} is
     * an opaque identifier that came from the server. This method sends those
     * identifiers back to the MarketBillingService, which ACKs them to the
     * server. Returns false if there was an error trying to connect to the
     * MarketBillingService.
     * @param startId an identifier for the invocation instance of this service
     * @param notifyIds a list of opaque identifiers associated with purchase
     * state changes.
     * @return false if there was an error connecting to Market
     */
    private boolean confirmNotifications(int startId, String[] notifyIds) {
        return new ConfirmNotifications(startId, notifyIds).runRequest();
    }

    /**
     * Gets the purchase information. This message includes a list of
     * notification IDs sent to us by Android Market, which we include in
     * our request. The server responds with the purchase information,
     * encoded as a JSON string, and sends that to the {@link BillingReceiver}
     * in an intent with the action {@link BillingConstants#ACTION_PURCHASE_STATE_CHANGED}.
     * Returns false if there was an error trying to connect to the MarketBillingService.
     *
     * @param startId an identifier for the invocation instance of this service
     * @param notifyIds a list of opaque identifiers associated with purchase
     * state changes
     * @return false if there was an error connecting to Android Market
     */
    private boolean getPurchaseInformation(int startId, String[] notifyIds) {
        return new GetPurchaseInformation(startId, notifyIds).runRequest();
    }

    /**
     * Verifies that the data was signed with the given signature, and calls
     * {@link ResponseHandler#purchaseResponse(Context, PurchaseState, String, String, long)}
     * for each verified purchase.
     * @param startId an identifier for the invocation instance of this service
     * @param signedData the signed JSON string (signed, not encrypted)
     * @param signature the signature for the data, signed with the private key
     */
    private void purchaseStateChanged(int startId, String signedData, String signature) {
        ArrayList<Security.VerifiedPurchase> purchases;
        purchases = Security.verifyPurchase(signedData, signature);
        if (purchases == null) {
            return;
        }

        ArrayList<String> notifyList = new ArrayList<String>();
        for (VerifiedPurchase vp : purchases) {
            if (vp.notificationId != null) {
                notifyList.add(vp.notificationId);
            }
            ResponseHandler.purchaseResponse(this, vp.purchaseState, vp.productId,
                    vp.orderId, vp.purchaseTime, vp.developerPayload, vp.purchaseToken);
        }
        if (!notifyList.isEmpty()) {
            String[] notifyIds = notifyList.toArray(new String[notifyList.size()]);
            confirmNotifications(startId, notifyIds);
        }
    }

    /**
     * This is called when we receive a response code from Android Market for a request
     * that we made. This is used for reporting various errors and for
     * acknowledging that an order was sent to the server. This is NOT used
     * for any purchase state changes.  All purchase state changes are received
     * in the {@link BillingReceiver} and passed to this service, where they are
     * handled in {@link #purchaseStateChanged(int, String, String)}.
     * @param requestId a number that identifies a request, assigned at the
     * time the request was made to Android Market
     * @param responseCode a response code from Android Market to indicate the state
     * of the request
     */
    private void checkResponseCode(long requestId, ResponseCode responseCode) {
        BillingRequest request = mSentRequests.get(requestId);
        if (request != null) {
            if (BillingConstants.DEBUG) {
                Log.d(TAG, request.getClass().getSimpleName() + ": " + responseCode);
            }
            request.responseCodeReceived(responseCode);
        }
        mSentRequests.remove(requestId);
    }

    /**
     * Runs any pending requests that are waiting for a connection to the
     * service to be established.  This runs in the main UI thread.
     */
    private void runPendingRequests() {
        int maxStartId = -1;
        BillingRequest request;
        while ((request = mPendingRequests.peek()) != null) {
            if (request.runIfConnected()) {
                // Remove the request
                mPendingRequests.remove();

                // Remember the largest startId, which is the most recent
                // request to start this service.
                if (maxStartId < request.getStartId()) {
                    maxStartId = request.getStartId();
                }
            } else {
                // The service crashed, so restart it. Note that this leaves
                // the current request on the queue.
                bindToMarketBillingService();
                return;
            }
        }

        // If we get here then all the requests ran successfully.  If maxStartId
        // is not -1, then one of the requests started the service, so we can
        // stop it now.
        if (maxStartId >= 0) {
            if (BillingConstants.DEBUG) {
                Log.i(TAG, "stopping service, startId: " + maxStartId);
            }
            stopSelf(maxStartId);
        }
    }

    /**
     * This is called when we are connected to the MarketBillingService.
     * This runs in the main UI thread.
     */
    @Override
    public void onServiceConnected(ComponentName name, IBinder service) {
        if (BillingConstants.DEBUG) {
            Log.d(TAG, "Billing service connected");
        }
        mService = IMarketBillingService.Stub.asInterface(service);
        runPendingRequests();
    }

    /**
     * This is called when we are disconnected from the MarketBillingService.
     */
    @Override
    public void onServiceDisconnected(ComponentName name) {
        Log.w(TAG, "Billing service disconnected");
        mService = null;
    }

    /**
     * Unbinds from the MarketBillingService. Call this when the application
     * terminates to avoid leaking a ServiceConnection.
     */
    public void unbind() {
        try {
            unbindService(this);
        } catch (IllegalArgumentException e) {
            // This might happen if the service was disconnected
        }
    }
}
