/*
 * Copyright (C) 2012 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.android.vending.expansion.downloader.impl;

import com.google.android.vending.expansion.downloader.Constants;
import com.google.android.vending.expansion.downloader.Helpers;
import com.google.android.vending.expansion.downloader.IDownloaderClient;

import org.apache.http.Header;
import org.apache.http.HttpHost;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.conn.params.ConnRouteParams;

import android.content.Context;
import android.net.Proxy;
import android.os.PowerManager;
import android.os.Process;
import android.util.Log;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.SyncFailedException;
import java.net.URI;
import java.net.URISyntaxException;
import java.util.Locale;

/**
 * Runs an actual download
 */
public class DownloadThread {

    private Context mContext;
    private DownloadInfo mInfo;
    private DownloaderService mService;
    private final DownloadsDB mDB;
    private final DownloadNotification mNotification;
    private String mUserAgent;

    public DownloadThread(DownloadInfo info, DownloaderService service,
            DownloadNotification notification) {
        mContext = service;
        mInfo = info;
        mService = service;
        mNotification = notification;
        mDB = DownloadsDB.getDB(service);
        mUserAgent = "APKXDL (Linux; U; Android " + android.os.Build.VERSION.RELEASE + ";"
                + Locale.getDefault().toString() + "; " + android.os.Build.DEVICE + "/"
                + android.os.Build.ID + ")" +
                service.getPackageName();
    }

    /**
     * Returns the default user agent
     */
    private String userAgent() {
        return mUserAgent;
    }

    /**
     * State for the entire run() method.
     */
    private static class State {
        public String mFilename;
        public FileOutputStream mStream;
        public boolean mCountRetry = false;
        public int mRetryAfter = 0;
        public int mRedirectCount = 0;
        public String mNewUri;
        public boolean mGotData = false;
        public String mRequestUri;

        public State(DownloadInfo info, DownloaderService service) {
            mRedirectCount = info.mRedirectCount;
            mRequestUri = info.mUri;
            mFilename = service.generateTempSaveFileName(info.mFileName);
        }
    }

    /**
     * State within executeDownload()
     */
    private static class InnerState {
        public int mBytesSoFar = 0;
        public int mBytesThisSession = 0;
        public String mHeaderETag;
        public boolean mContinuingDownload = false;
        public String mHeaderContentLength;
        public String mHeaderContentDisposition;
        public String mHeaderContentLocation;
        public int mBytesNotified = 0;
        public long mTimeLastNotification = 0;
    }

    /**
     * Raised from methods called by run() to indicate that the current request
     * should be stopped immediately. Note the message passed to this exception
     * will be logged and therefore must be guaranteed not to contain any PII,
     * meaning it generally can't include any information about the request URI,
     * headers, or destination filename.
     */
    private class StopRequest extends Throwable {
        /**
		 * 
		 */
        private static final long serialVersionUID = 6338592678988347973L;
        public int mFinalStatus;

        public StopRequest(int finalStatus, String message) {
            super(message);
            mFinalStatus = finalStatus;
        }

        public StopRequest(int finalStatus, String message, Throwable throwable) {
            super(message, throwable);
            mFinalStatus = finalStatus;
        }
    }

    /**
     * Raised from methods called by executeDownload() to indicate that the
     * download should be retried immediately.
     */
    private class RetryDownload extends Throwable {

        /**
		 * 
		 */
        private static final long serialVersionUID = 6196036036517540229L;
    }

    /**
     * Returns the preferred proxy to be used by clients. This is a wrapper
     * around {@link android.net.Proxy#getHost()}. Currently no proxy will be
     * returned for localhost or if the active network is Wi-Fi.
     * 
     * @param context the context which will be passed to
     *            {@link android.net.Proxy#getHost()}
     * @param url the target URL for the request
     * @note Calling this method requires permission
     *       android.permission.ACCESS_NETWORK_STATE
     * @return The preferred proxy to be used by clients, or null if there is no
     *         proxy.
     */
    public HttpHost getPreferredHttpHost(Context context,
            String url) {
        if (!isLocalHost(url) && !mService.isWiFi()) {
            final String proxyHost = Proxy.getHost(context);
            if (proxyHost != null) {
                return new HttpHost(proxyHost, Proxy.getPort(context), "http");
            }
        }

        return null;
    }

    static final private boolean isLocalHost(String url) {
        if (url == null) {
            return false;
        }

        try {
            final URI uri = URI.create(url);
            final String host = uri.getHost();
            if (host != null) {
                // TODO: InetAddress.isLoopbackAddress should be used to check
                // for localhost. However no public factory methods exist which
                // can be used without triggering DNS lookup if host is not
                // localhost.
                if (host.equalsIgnoreCase("localhost") ||
                        host.equals("127.0.0.1") ||
                        host.equals("[::1]")) {
                    return true;
                }
            }
        } catch (IllegalArgumentException iex) {
            // Ignore (URI.create)
        }

        return false;
    }

    /**
     * Executes the download in a separate thread
     */
    public void run() {
        Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

        State state = new State(mInfo, mService);
        AndroidHttpClient client = null;
        PowerManager.WakeLock wakeLock = null;
        int finalStatus = DownloaderService.STATUS_UNKNOWN_ERROR;

        try {
            PowerManager pm = (PowerManager) mContext.getSystemService(Context.POWER_SERVICE);
            wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, Constants.TAG);
            wakeLock.acquire();

            if (Constants.LOGV) {
                Log.v(Constants.TAG, "initiating download for " + mInfo.mFileName);
                Log.v(Constants.TAG, "  at " + mInfo.mUri);
            }

            client = AndroidHttpClient.newInstance(userAgent(), mContext);

            boolean finished = false;
            while (!finished) {
                if (Constants.LOGV) {
                    Log.v(Constants.TAG, "initiating download for " + mInfo.mFileName);
                    Log.v(Constants.TAG, "  at " + mInfo.mUri);
                }
                // Set or unset proxy, which may have changed since last GET
                // request.
                // setDefaultProxy() supports null as proxy parameter.
                ConnRouteParams.setDefaultProxy(client.getParams(),
                        getPreferredHttpHost(mContext, state.mRequestUri));
                HttpGet request = new HttpGet(state.mRequestUri);
                try {
                    executeDownload(state, client, request);
                    finished = true;
                } catch (RetryDownload exc) {
                    // fall through
                } finally {
                    request.abort();
                    request = null;
                }
            }

            if (Constants.LOGV) {
                Log.v(Constants.TAG, "download completed for " + mInfo.mFileName);
                Log.v(Constants.TAG, "  at " + mInfo.mUri);
            }
            finalizeDestinationFile(state);
            finalStatus = DownloaderService.STATUS_SUCCESS;
        } catch (StopRequest error) {
            // remove the cause before printing, in case it contains PII
            Log.w(Constants.TAG,
                    "Aborting request for download " + mInfo.mFileName + ": " + error.getMessage());
            error.printStackTrace();
            finalStatus = error.mFinalStatus;
            // fall through to finally block
        } catch (Throwable ex) { // sometimes the socket code throws unchecked
                                 // exceptions
            Log.w(Constants.TAG, "Exception for " + mInfo.mFileName + ": " + ex);
            finalStatus = DownloaderService.STATUS_UNKNOWN_ERROR;
            // falls through to the code that reports an error
        } finally {
            if (wakeLock != null) {
                wakeLock.release();
                wakeLock = null;
            }
            if (client != null) {
                client.close();
                client = null;
            }
            cleanupDestination(state, finalStatus);
            notifyDownloadCompleted(finalStatus, state.mCountRetry, state.mRetryAfter,
                    state.mRedirectCount, state.mGotData, state.mFilename);
        }
    }

    /**
     * Fully execute a single download request - setup and send the request,
     * handle the response, and transfer the data to the destination file.
     */
    private void executeDownload(State state, AndroidHttpClient client, HttpGet request)
            throws StopRequest, RetryDownload {
        InnerState innerState = new InnerState();
        byte data[] = new byte[Constants.BUFFER_SIZE];

        checkPausedOrCanceled(state);

        setupDestinationFile(state, innerState);
        addRequestHeaders(innerState, request);

        // check just before sending the request to avoid using an invalid
        // connection at all
        checkConnectivity(state);

        mNotification.onDownloadStateChanged(IDownloaderClient.STATE_CONNECTING);
        HttpResponse response = sendRequest(state, client, request);
        handleExceptionalStatus(state, innerState, response);

        if (Constants.LOGV) {
            Log.v(Constants.TAG, "received response for " + mInfo.mUri);
        }

        processResponseHeaders(state, innerState, response);
        InputStream entityStream = openResponseEntity(state, response);
        mNotification.onDownloadStateChanged(IDownloaderClient.STATE_DOWNLOADING);
        transferData(state, innerState, data, entityStream);
    }

    /**
     * Check if current connectivity is valid for this request.
     */
    private void checkConnectivity(State state) throws StopRequest {
        switch (mService.getNetworkAvailabilityState(mDB)) {
            case DownloaderService.NETWORK_OK:
                return;
            case DownloaderService.NETWORK_NO_CONNECTION:
                throw new StopRequest(DownloaderService.STATUS_WAITING_FOR_NETWORK,
                        "waiting for network to return");
            case DownloaderService.NETWORK_TYPE_DISALLOWED_BY_REQUESTOR:
                throw new StopRequest(
                        DownloaderService.STATUS_QUEUED_FOR_WIFI_OR_CELLULAR_PERMISSION,
                        "waiting for wifi or for download over cellular to be authorized");
            case DownloaderService.NETWORK_CANNOT_USE_ROAMING:
                throw new StopRequest(DownloaderService.STATUS_WAITING_FOR_NETWORK,
                        "roaming is not allowed");
            case DownloaderService.NETWORK_UNUSABLE_DUE_TO_SIZE:
                throw new StopRequest(DownloaderService.STATUS_QUEUED_FOR_WIFI, "waiting for wifi");
        }
    }

    /**
     * Transfer as much data as possible from the HTTP response to the
     * destination file.
     * 
     * @param data buffer to use to read data
     * @param entityStream stream for reading the HTTP response entity
     */
    private void transferData(State state, InnerState innerState, byte[] data,
            InputStream entityStream) throws StopRequest {
        for (;;) {
            int bytesRead = readFromResponse(state, innerState, data, entityStream);
            if (bytesRead == -1) { // success, end of stream already reached
                handleEndOfStream(state, innerState);
                return;
            }

            state.mGotData = true;
            writeDataToDestination(state, data, bytesRead);
            innerState.mBytesSoFar += bytesRead;
            innerState.mBytesThisSession += bytesRead;
            reportProgress(state, innerState);

            checkPausedOrCanceled(state);
        }
    }

    /**
     * Called after a successful completion to take any necessary action on the
     * downloaded file.
     */
    private void finalizeDestinationFile(State state) throws StopRequest {
        syncDestination(state);
        String tempFilename = state.mFilename;
        String finalFilename = Helpers.generateSaveFileName(mService, mInfo.mFileName);
        if (!state.mFilename.equals(finalFilename)) {
            File startFile = new File(tempFilename);
            File destFile = new File(finalFilename);
            if (mInfo.mTotalBytes != -1 && mInfo.mCurrentBytes == mInfo.mTotalBytes) {
                if (!startFile.renameTo(destFile)) {
                    throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
                            "unable to finalize destination file");
                }
            } else {
                throw new StopRequest(DownloaderService.STATUS_FILE_DELIVERED_INCORRECTLY,
                        "file delivered with incorrect size. probably due to network not browser configured");
            }
        }
    }

    /**
     * Called just before the thread finishes, regardless of status, to take any
     * necessary action on the downloaded file.
     */
    private void cleanupDestination(State state, int finalStatus) {
        closeDestination(state);
        if (state.mFilename != null && DownloaderService.isStatusError(finalStatus)) {
            new File(state.mFilename).delete();
            state.mFilename = null;
        }
    }

    /**
     * Sync the destination file to storage.
     */
    private void syncDestination(State state) {
        FileOutputStream downloadedFileStream = null;
        try {
            downloadedFileStream = new FileOutputStream(state.mFilename, true);
            downloadedFileStream.getFD().sync();
        } catch (FileNotFoundException ex) {
            Log.w(Constants.TAG, "file " + state.mFilename + " not found: " + ex);
        } catch (SyncFailedException ex) {
            Log.w(Constants.TAG, "file " + state.mFilename + " sync failed: " + ex);
        } catch (IOException ex) {
            Log.w(Constants.TAG, "IOException trying to sync " + state.mFilename + ": " + ex);
        } catch (RuntimeException ex) {
            Log.w(Constants.TAG, "exception while syncing file: ", ex);
        } finally {
            if (downloadedFileStream != null) {
                try {
                    downloadedFileStream.close();
                } catch (IOException ex) {
                    Log.w(Constants.TAG, "IOException while closing synced file: ", ex);
                } catch (RuntimeException ex) {
                    Log.w(Constants.TAG, "exception while closing file: ", ex);
                }
            }
        }
    }

    /**
     * Close the destination output stream.
     */
    private void closeDestination(State state) {
        try {
            // close the file
            if (state.mStream != null) {
                state.mStream.close();
                state.mStream = null;
            }
        } catch (IOException ex) {
            if (Constants.LOGV) {
                Log.v(Constants.TAG, "exception when closing the file after download : " + ex);
            }
            // nothing can really be done if the file can't be closed
        }
    }

    /**
     * Check if the download has been paused or canceled, stopping the request
     * appropriately if it has been.
     */
    private void checkPausedOrCanceled(State state) throws StopRequest {
        if (mService.getControl() == DownloaderService.CONTROL_PAUSED) {
            int status = mService.getStatus();
            switch (status) {
                case DownloaderService.STATUS_PAUSED_BY_APP:
                    throw new StopRequest(mService.getStatus(),
                            "download paused");
            }
        }
    }

    /**
     * Report download progress through the database if necessary.
     */
    private void reportProgress(State state, InnerState innerState) {
        long now = System.currentTimeMillis();
        if (innerState.mBytesSoFar - innerState.mBytesNotified
                > Constants.MIN_PROGRESS_STEP
                && now - innerState.mTimeLastNotification
                > Constants.MIN_PROGRESS_TIME) {
            // we store progress updates to the database here
            mInfo.mCurrentBytes = innerState.mBytesSoFar;
            mDB.updateDownloadCurrentBytes(mInfo);

            innerState.mBytesNotified = innerState.mBytesSoFar;
            innerState.mTimeLastNotification = now;

            long totalBytesSoFar = innerState.mBytesThisSession + mService.mBytesSoFar;

            if (Constants.LOGVV) {
                Log.v(Constants.TAG, "downloaded " + mInfo.mCurrentBytes + " out of "
                        + mInfo.mTotalBytes);
                Log.v(Constants.TAG, "     total " + totalBytesSoFar + " out of "
                        + mService.mTotalLength);
            }

            mService.notifyUpdateBytes(totalBytesSoFar);
        }
    }

    /**
     * Write a data buffer to the destination file.
     * 
     * @param data buffer containing the data to write
     * @param bytesRead how many bytes to write from the buffer
     */
    private void writeDataToDestination(State state, byte[] data, int bytesRead)
            throws StopRequest {
        for (;;) {
            try {
                if (state.mStream == null) {
                    state.mStream = new FileOutputStream(state.mFilename, true);
                }
                state.mStream.write(data, 0, bytesRead);
                // we close after every write --- this may be too inefficient
                closeDestination(state);
                return;
            } catch (IOException ex) {
                if (!Helpers.isExternalMediaMounted()) {
                    throw new StopRequest(DownloaderService.STATUS_DEVICE_NOT_FOUND_ERROR,
                            "external media not mounted while writing destination file");
                }

                long availableBytes =
                        Helpers.getAvailableBytes(Helpers.getFilesystemRoot(state.mFilename));
                if (availableBytes < bytesRead) {
                    throw new StopRequest(DownloaderService.STATUS_INSUFFICIENT_SPACE_ERROR,
                            "insufficient space while writing destination file", ex);
                }
                throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
                        "while writing destination file: " + ex.toString(), ex);
            }
        }
    }

    /**
     * Called when we've reached the end of the HTTP response stream, to update
     * the database and check for consistency.
     */
    private void handleEndOfStream(State state, InnerState innerState) throws StopRequest {
        mInfo.mCurrentBytes = innerState.mBytesSoFar;
        // this should always be set from the market
        // if ( innerState.mHeaderContentLength == null ) {
        // mInfo.mTotalBytes = innerState.mBytesSoFar;
        // }
        mDB.updateDownload(mInfo);

        boolean lengthMismatched = (innerState.mHeaderContentLength != null)
                && (innerState.mBytesSoFar != Integer.parseInt(innerState.mHeaderContentLength));
        if (lengthMismatched) {
            if (cannotResume(innerState)) {
                throw new StopRequest(DownloaderService.STATUS_CANNOT_RESUME,
                        "mismatched content length");
            } else {
                throw new StopRequest(getFinalStatusForHttpError(state),
                        "closed socket before end of file");
            }
        }
    }

    private boolean cannotResume(InnerState innerState) {
        return innerState.mBytesSoFar > 0 && innerState.mHeaderETag == null;
    }

    /**
     * Read some data from the HTTP response stream, handling I/O errors.
     * 
     * @param data buffer to use to read data
     * @param entityStream stream for reading the HTTP response entity
     * @return the number of bytes actually read or -1 if the end of the stream
     *         has been reached
     */
    private int readFromResponse(State state, InnerState innerState, byte[] data,
            InputStream entityStream) throws StopRequest {
        try {
            return entityStream.read(data);
        } catch (IOException ex) {
            logNetworkState();
            mInfo.mCurrentBytes = innerState.mBytesSoFar;
            mDB.updateDownload(mInfo);
            if (cannotResume(innerState)) {
                String message = "while reading response: " + ex.toString()
                        + ", can't resume interrupted download with no ETag";
                throw new StopRequest(DownloaderService.STATUS_CANNOT_RESUME,
                        message, ex);
            } else {
                throw new StopRequest(getFinalStatusForHttpError(state),
                        "while reading response: " + ex.toString(), ex);
            }
        }
    }

    /**
     * Open a stream for the HTTP response entity, handling I/O errors.
     * 
     * @return an InputStream to read the response entity
     */
    private InputStream openResponseEntity(State state, HttpResponse response)
            throws StopRequest {
        try {
            return response.getEntity().getContent();
        } catch (IOException ex) {
            logNetworkState();
            throw new StopRequest(getFinalStatusForHttpError(state),
                    "while getting entity: " + ex.toString(), ex);
        }
    }

    private void logNetworkState() {
        if (Constants.LOGX) {
            Log.i(Constants.TAG,
                    "Net "
                            + (mService.getNetworkAvailabilityState(mDB) == DownloaderService.NETWORK_OK ? "Up"
                                    : "Down"));
        }
    }

    /**
     * Read HTTP response headers and take appropriate action, including setting
     * up the destination file and updating the database.
     */
    private void processResponseHeaders(State state, InnerState innerState, HttpResponse response)
            throws StopRequest {
        if (innerState.mContinuingDownload) {
            // ignore response headers on resume requests
            return;
        }

        readResponseHeaders(state, innerState, response);

        try {
            state.mFilename = mService.generateSaveFile(mInfo.mFileName, mInfo.mTotalBytes);
        } catch (DownloaderService.GenerateSaveFileError exc) {
            throw new StopRequest(exc.mStatus, exc.mMessage);
        }
        try {
            state.mStream = new FileOutputStream(state.mFilename);
        } catch (FileNotFoundException exc) {
            // make sure the directory exists
            File pathFile = new File(Helpers.getSaveFilePath(mService));
            try {
                if (pathFile.mkdirs()) {
                    state.mStream = new FileOutputStream(state.mFilename);
                }
            } catch (Exception ex) {
                throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
                        "while opening destination file: " + exc.toString(), exc);
            }
        }
        if (Constants.LOGV) {
            Log.v(Constants.TAG, "writing " + mInfo.mUri + " to " + state.mFilename);
        }

        updateDatabaseFromHeaders(state, innerState);
        // check connectivity again now that we know the total size
        checkConnectivity(state);
    }

    /**
     * Update necessary database fields based on values of HTTP response headers
     * that have been read.
     */
    private void updateDatabaseFromHeaders(State state, InnerState innerState) {
        mInfo.mETag = innerState.mHeaderETag;
        mDB.updateDownload(mInfo);
    }

    /**
     * Read headers from the HTTP response and store them into local state.
     */
    private void readResponseHeaders(State state, InnerState innerState, HttpResponse response)
            throws StopRequest {
        Header header = response.getFirstHeader("Content-Disposition");
        if (header != null) {
            innerState.mHeaderContentDisposition = header.getValue();
        }
        header = response.getFirstHeader("Content-Location");
        if (header != null) {
            innerState.mHeaderContentLocation = header.getValue();
        }
        header = response.getFirstHeader("ETag");
        if (header != null) {
            innerState.mHeaderETag = header.getValue();
        }
        String headerTransferEncoding = null;
        header = response.getFirstHeader("Transfer-Encoding");
        if (header != null) {
            headerTransferEncoding = header.getValue();
        }
        String headerContentType = null;
        header = response.getFirstHeader("Content-Type");
        if (header != null) {
            headerContentType = header.getValue();
            if (!headerContentType.equals("application/vnd.android.obb")) {
                throw new StopRequest(DownloaderService.STATUS_FILE_DELIVERED_INCORRECTLY,
                        "file delivered with incorrect Mime type");
            }
        }

        if (headerTransferEncoding == null) {
            header = response.getFirstHeader("Content-Length");
            if (header != null) {
                innerState.mHeaderContentLength = header.getValue();
                // this is always set from Market
                long contentLength = Long.parseLong(innerState.mHeaderContentLength);
                if (contentLength != -1 && contentLength != mInfo.mTotalBytes) {
                    // we're most likely on a bad wifi connection -- we should
                    // probably
                    // also look at the mime type --- but the size mismatch is
                    // enough
                    // to tell us that something is wrong here
                    Log.e(Constants.TAG, "Incorrect file size delivered.");
                }
            }
        } else {
            // Ignore content-length with transfer-encoding - 2616 4.4 3
            if (Constants.LOGVV) {
                Log.v(Constants.TAG,
                        "ignoring content-length because of xfer-encoding");
            }
        }
        if (Constants.LOGVV) {
            Log.v(Constants.TAG, "Content-Disposition: " +
                    innerState.mHeaderContentDisposition);
            Log.v(Constants.TAG, "Content-Length: " + innerState.mHeaderContentLength);
            Log.v(Constants.TAG, "Content-Location: " + innerState.mHeaderContentLocation);
            Log.v(Constants.TAG, "ETag: " + innerState.mHeaderETag);
            Log.v(Constants.TAG, "Transfer-Encoding: " + headerTransferEncoding);
        }

        boolean noSizeInfo = innerState.mHeaderContentLength == null
                && (headerTransferEncoding == null
                || !headerTransferEncoding.equalsIgnoreCase("chunked"));
        if (noSizeInfo) {
            throw new StopRequest(DownloaderService.STATUS_HTTP_DATA_ERROR,
                    "can't know size of download, giving up");
        }
    }

    /**
     * Check the HTTP response status and handle anything unusual (e.g. not
     * 200/206).
     */
    private void handleExceptionalStatus(State state, InnerState innerState, HttpResponse response)
            throws StopRequest, RetryDownload {
        int statusCode = response.getStatusLine().getStatusCode();
        if (statusCode == 503 && mInfo.mNumFailed < Constants.MAX_RETRIES) {
            handleServiceUnavailable(state, response);
        }
        if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 307) {
            handleRedirect(state, response, statusCode);
        }

        int expectedStatus = innerState.mContinuingDownload ? 206
                : DownloaderService.STATUS_SUCCESS;
        if (statusCode != expectedStatus) {
            handleOtherStatus(state, innerState, statusCode);
        } else {
            // no longer redirected
            state.mRedirectCount = 0;
        }
    }

    /**
     * Handle a status that we don't know how to deal with properly.
     */
    private void handleOtherStatus(State state, InnerState innerState, int statusCode)
            throws StopRequest {
        int finalStatus;
        if (DownloaderService.isStatusError(statusCode)) {
            finalStatus = statusCode;
        } else if (statusCode >= 300 && statusCode < 400) {
            finalStatus = DownloaderService.STATUS_UNHANDLED_REDIRECT;
        } else if (innerState.mContinuingDownload && statusCode == DownloaderService.STATUS_SUCCESS) {
            finalStatus = DownloaderService.STATUS_CANNOT_RESUME;
        } else {
            finalStatus = DownloaderService.STATUS_UNHANDLED_HTTP_CODE;
        }
        throw new StopRequest(finalStatus, "http error " + statusCode);
    }

    /**
     * Handle a 3xx redirect status.
     */
    private void handleRedirect(State state, HttpResponse response, int statusCode)
            throws StopRequest, RetryDownload {
        if (Constants.LOGVV) {
            Log.v(Constants.TAG, "got HTTP redirect " + statusCode);
        }
        if (state.mRedirectCount >= Constants.MAX_REDIRECTS) {
            throw new StopRequest(DownloaderService.STATUS_TOO_MANY_REDIRECTS, "too many redirects");
        }
        Header header = response.getFirstHeader("Location");
        if (header == null) {
            return;
        }
        if (Constants.LOGVV) {
            Log.v(Constants.TAG, "Location :" + header.getValue());
        }

        String newUri;
        try {
            newUri = new URI(mInfo.mUri).resolve(new URI(header.getValue())).toString();
        } catch (URISyntaxException ex) {
            if (Constants.LOGV) {
                Log.d(Constants.TAG, "Couldn't resolve redirect URI " + header.getValue()
                        + " for " + mInfo.mUri);
            }
            throw new StopRequest(DownloaderService.STATUS_HTTP_DATA_ERROR,
                    "Couldn't resolve redirect URI");
        }
        ++state.mRedirectCount;
        state.mRequestUri = newUri;
        if (statusCode == 301 || statusCode == 303) {
            // use the new URI for all future requests (should a retry/resume be
            // necessary)
            state.mNewUri = newUri;
        }
        throw new RetryDownload();
    }

    /**
     * Add headers for this download to the HTTP request to allow for resume.
     */
    private void addRequestHeaders(InnerState innerState, HttpGet request) {
        if (innerState.mContinuingDownload) {
            if (innerState.mHeaderETag != null) {
                request.addHeader("If-Match", innerState.mHeaderETag);
            }
            request.addHeader("Range", "bytes=" + innerState.mBytesSoFar + "-");
        }
    }

    /**
     * Handle a 503 Service Unavailable status by processing the Retry-After
     * header.
     */
    private void handleServiceUnavailable(State state, HttpResponse response) throws StopRequest {
        if (Constants.LOGVV) {
            Log.v(Constants.TAG, "got HTTP response code 503");
        }
        state.mCountRetry = true;
        Header header = response.getFirstHeader("Retry-After");
        if (header != null) {
            try {
                if (Constants.LOGVV) {
                    Log.v(Constants.TAG, "Retry-After :" + header.getValue());
                }
                state.mRetryAfter = Integer.parseInt(header.getValue());
                if (state.mRetryAfter < 0) {
                    state.mRetryAfter = 0;
                } else {
                    if (state.mRetryAfter < Constants.MIN_RETRY_AFTER) {
                        state.mRetryAfter = Constants.MIN_RETRY_AFTER;
                    } else if (state.mRetryAfter > Constants.MAX_RETRY_AFTER) {
                        state.mRetryAfter = Constants.MAX_RETRY_AFTER;
                    }
                    state.mRetryAfter += Helpers.sRandom.nextInt(Constants.MIN_RETRY_AFTER + 1);
                    state.mRetryAfter *= 1000;
                }
            } catch (NumberFormatException ex) {
                // ignored - retryAfter stays 0 in this case.
            }
        }
        throw new StopRequest(DownloaderService.STATUS_WAITING_TO_RETRY,
                "got 503 Service Unavailable, will retry later");
    }

    /**
     * Send the request to the server, handling any I/O exceptions.
     */
    private HttpResponse sendRequest(State state, AndroidHttpClient client, HttpGet request)
            throws StopRequest {
        try {
            return client.execute(request);
        } catch (IllegalArgumentException ex) {
            throw new StopRequest(DownloaderService.STATUS_HTTP_DATA_ERROR,
                    "while trying to execute request: " + ex.toString(), ex);
        } catch (IOException ex) {
            logNetworkState();
            throw new StopRequest(getFinalStatusForHttpError(state),
                    "while trying to execute request: " + ex.toString(), ex);
        }
    }

    private int getFinalStatusForHttpError(State state) {
        if (mService.getNetworkAvailabilityState(mDB) != DownloaderService.NETWORK_OK) {
            return DownloaderService.STATUS_WAITING_FOR_NETWORK;
        } else if (mInfo.mNumFailed < Constants.MAX_RETRIES) {
            state.mCountRetry = true;
            return DownloaderService.STATUS_WAITING_TO_RETRY;
        } else {
            Log.w(Constants.TAG, "reached max retries for " + mInfo.mNumFailed);
            return DownloaderService.STATUS_HTTP_DATA_ERROR;
        }
    }

    /**
     * Prepare the destination file to receive data. If the file already exists,
     * we'll set up appropriately for resumption.
     */
    private void setupDestinationFile(State state, InnerState innerState)
            throws StopRequest {
        if (state.mFilename != null) { // only true if we've already run a
                                       // thread for this download
            if (!Helpers.isFilenameValid(state.mFilename)) {
                // this should never happen
                throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
                        "found invalid internal destination filename");
            }
            // We're resuming a download that got interrupted
            File f = new File(state.mFilename);
            if (f.exists()) {
                long fileLength = f.length();
                if (fileLength == 0) {
                    // The download hadn't actually started, we can restart from
                    // scratch
                    f.delete();
                    state.mFilename = null;
                } else if (mInfo.mETag == null) {
                    // This should've been caught upon failure
                    f.delete();
                    throw new StopRequest(DownloaderService.STATUS_CANNOT_RESUME,
                            "Trying to resume a download that can't be resumed");
                } else {
                    // All right, we'll be able to resume this download
                    try {
                        state.mStream = new FileOutputStream(state.mFilename, true);
                    } catch (FileNotFoundException exc) {
                        throw new StopRequest(DownloaderService.STATUS_FILE_ERROR,
                                "while opening destination for resuming: " + exc.toString(), exc);
                    }
                    innerState.mBytesSoFar = (int) fileLength;
                    if (mInfo.mTotalBytes != -1) {
                        innerState.mHeaderContentLength = Long.toString(mInfo.mTotalBytes);
                    }
                    innerState.mHeaderETag = mInfo.mETag;
                    innerState.mContinuingDownload = true;
                }
            }
        }

        if (state.mStream != null) {
            closeDestination(state);
        }
    }

    /**
     * Stores information about the completed download, and notifies the
     * initiating application.
     */
    private void notifyDownloadCompleted(
            int status, boolean countRetry, int retryAfter, int redirectCount, boolean gotData,
            String filename) {
        updateDownloadDatabase(
                status, countRetry, retryAfter, redirectCount, gotData, filename);
        if (DownloaderService.isStatusCompleted(status)) {
            // TBD: send status update?
        }
    }

    private void updateDownloadDatabase(
            int status, boolean countRetry, int retryAfter, int redirectCount, boolean gotData,
            String filename) {
        mInfo.mStatus = status;
        mInfo.mRetryAfter = retryAfter;
        mInfo.mRedirectCount = redirectCount;
        mInfo.mLastMod = System.currentTimeMillis();
        if (!countRetry) {
            mInfo.mNumFailed = 0;
        } else if (gotData) {
            mInfo.mNumFailed = 1;
        } else {
            mInfo.mNumFailed++;
        }
        mDB.updateDownload(mInfo);
    }

}
