/*
 * Copyright (C) 2013 Google Inc.
 *
 * 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.googlecode.eyesfree.labeling;

import android.annotation.TargetApi;
import android.content.ContentProviderClient;
import android.content.ContentResolver;
import android.content.ContentUris;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.RemoteException;
import android.util.Log;
import com.googlecode.eyesfree.utils.LogUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
 * A client for storing and retrieving custom TalkBack view labels using the
 * {@link Label} model class and a connection to a
 * {@link android.content.ContentProvider} for labels.
 *
 * @author [email protected] (Austin Davis)
 */
@TargetApi(Build.VERSION_CODES.JELLY_BEAN_MR2)
public class LabelProviderClient {
    private static final String EQUALS_ARGUMENT = " = ?";
    private static final String LEQ_ARGUMENT = " <= ?";
    private static final String AND = " AND ";
    private static final String GET_LABELS_FOR_APPLICATION_QUERY_WHERE = new StringBuilder()
            .append(LabelsTable.KEY_PACKAGE_NAME).append(EQUALS_ARGUMENT)
            .append(AND).append(LabelsTable.KEY_LOCALE).append(EQUALS_ARGUMENT)
            .append(AND).append(LabelsTable.KEY_PACKAGE_VERSION).append(LEQ_ARGUMENT)
            .toString();
    private static final String GET_LABEL_QUERY_WHERE = new StringBuilder()
            .append(LabelsTable.KEY_PACKAGE_NAME).append(EQUALS_ARGUMENT)
            .append(AND).append(LabelsTable.KEY_VIEW_NAME).append(EQUALS_ARGUMENT)
            .append(AND).append(LabelsTable.KEY_LOCALE).append(EQUALS_ARGUMENT)
            .append(AND).append(LabelsTable.KEY_PACKAGE_VERSION).append(LEQ_ARGUMENT)
            .toString();
    private static final String PACKAGE_SUMMARY_QUERY_WHERE = new StringBuilder()
            .append(LabelsTable.KEY_LOCALE).append(EQUALS_ARGUMENT)
            .toString();

    private static final String LABELS_PATH = "labels";
    private static final String PACKAGE_SUMMARY_PATH = "packageSummary";

    private ContentProviderClient mClient;
    private final Uri mLabelsContentUri;
    private final Uri mPackageSummaryContentUri;

    /**
     * Constructs a new client instance for the provider at the given URI.
     *
     * @param context The current context.
     * @param authority The authority of the labels content provider to access.
     */
    public LabelProviderClient(Context context, String authority) {
        mLabelsContentUri = new Uri.Builder()
                .scheme("content")
                .authority(authority)
                .path(LABELS_PATH)
                .build();
        mPackageSummaryContentUri = new Uri.Builder()
                .scheme("content")
                .authority(authority)
                .path(PACKAGE_SUMMARY_PATH)
                .build();

        final ContentResolver contentResolver = context.getContentResolver();
        mClient = contentResolver.acquireContentProviderClient(mLabelsContentUri);

        if (mClient == null) {
            LogUtils.log(this, Log.WARN, "Failed to acquire content provider client.");
        }
    }

    /**
     * Inserts the specified label into the labels database via a client for the
     * labels {@link android.content.ContentProvider}.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param label The model object for the label to store in the database.
     * @return A new label object with the assigned label ID from the database,
     *         or {@code null} if the insert operation failed.
     */
    public Label insertLabel(Label label) {
        LogUtils.log(this, Log.DEBUG, "Inserting label: %s.", label);

        if (label == null) {
            return null;
        }

        final long labelId = label.getId();
        if (label.getId() != Label.NO_ID) {
            LogUtils.log(this, Log.WARN, "Cannot insert label with existing ID (id=%d).", labelId);
            return null;
        }

        if (!checkClient()) {
            return null;
        }

        final ContentValues values = buildContentValuesForLabel(label);

        final Uri resultUri;
        try {
            resultUri = mClient.insert(mLabelsContentUri, values);
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return null;
        }

        if (resultUri == null) {
            LogUtils.log(this, Log.WARN, "Failed to insert label.");
            return null;
        }

        final long newLabelId = Long.parseLong(resultUri.getLastPathSegment());
        return new Label(label, newLabelId);
    }

    /**
     * Gets a list of all labels in the label database.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @return An unmodifiable list of all labels in the database,
     *         or an empty list if the query returns no results,
     *         or {@code null} if the query fails.
     */
    public List<Label> getAllLabels() {
        LogUtils.log(this, Log.DEBUG, "Querying all labels.");

        if (!checkClient()) {
            return null;
        }

        Cursor cursor = null;
        try {
            cursor = mClient.query(mLabelsContentUri, LabelsTable.ALL_COLUMNS /* projection */,
                    null /* where */, null /* whereArgs */, null /* sortOrder */);

            return getLabelListFromCursor(cursor);
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return null;
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Gets a summary of label info for each package from the label database.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @return An unmodifiable list of {@link PackageLabelInfo} objects, or an
     *         empty map if the query returns no results, or {@code null} if the
     *         query fails.
     */
    public List<PackageLabelInfo> getPackageSummary(String locale) {
        LogUtils.log(this, Log.DEBUG, "Querying package summary.");

        if (!checkClient()) {
            return null;
        }

        final String[] whereArgs = { locale };

        Cursor cursor = null;
        try {
            cursor = mClient.query(mPackageSummaryContentUri, null /* projection */,
                    PACKAGE_SUMMARY_QUERY_WHERE, whereArgs, null /* sortOrder */);

            return getPackageSummaryFromCursor(cursor);
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return null;
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Queries for labels matching a particular package and locale.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param packageName The package name to match.
     * @param locale The locale to match.
     * @param maxPackageVersion The maximum package version for result labels.
     * @return An unmodifiable map from view names to label objects that
     *         contains all labels matching the criteria, or {@code null} if the
     *         query failed.
     */
    public Map<String, Label> getLabelsForPackage(String packageName, String locale,
            int maxPackageVersion) {
        LogUtils.log(this, Log.DEBUG,
                "Querying labels for package: packageName=%s, locale=%s, maxPackageVersion=%s.",
                packageName, locale, maxPackageVersion);

        if (!checkClient()) {
            return null;
        }

        final String[] whereArgs = new String[] {
                packageName, locale, Integer.toString(maxPackageVersion) };

        Cursor cursor = null;
        try {
            cursor = mClient.query(mLabelsContentUri, LabelsTable.ALL_COLUMNS /* projection */,
                    GET_LABELS_FOR_APPLICATION_QUERY_WHERE, whereArgs,
                    null /* sortOrder */);

            return getLabelMapFromCursor(cursor);
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return null;
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Queries for labels matching a particular package and locale for all
     * versions of that package.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param packageName The package name to match.
     * @param locale The locale to match.
     * @return An unmodifiable map from view names to label objects that
     *         contains all labels matching the criteria, or {@code null} if the
     *         query failed.
     */
    public Map<String, Label> getLabelsForPackage(String packageName, String locale) {
        return getLabelsForPackage(packageName, locale, Integer.MAX_VALUE);
    }

    /**
     * Queries for a single label matching a particular view and locale.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param packageName The package name for the view's application.
     * @param viewName The identifier of the view for which to find a label.
     * @param locale The desired locale for the label.
     * @param maxPackageVersion The maximum package version for result labels.
     * @return A single label matching the criteria,
     *         or {@code null} if no matching label was found.
     */
    public Label getLabel(String packageName, String viewName, String locale,
            int maxPackageVersion) {
        LogUtils.log(this, Log.DEBUG, "Querying single label: " +
                "packageName=%s, viewName=%s, locale=%s, maxPackageVersion=%s.",
                packageName, viewName, locale, maxPackageVersion);

        if (!checkClient()) {
            return null;
        }

        final String[] whereArgs = new String[] {
                packageName, viewName, locale, Integer.toString(maxPackageVersion) };
        Cursor cursor = null;
        try {
            cursor = mClient.query(mLabelsContentUri, LabelsTable.ALL_COLUMNS,
                    GET_LABEL_QUERY_WHERE, whereArgs, null /* sortOrder */);

            return getLabelFromCursor(cursor);
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return null;
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Queries for a single label by its label ID.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param id The ID of the label to find.
     * @return The label with the given ID,
     *         or {@code null} if no such label was found.
     */
    public Label getLabelById(long id) {
        LogUtils.log(this, Log.DEBUG, "Querying single label: id=%d.", id);

        if (!checkClient()) {
            return null;
        }

        final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, id);
        Cursor cursor = null;
        try {
            cursor = mClient.query(uri, LabelsTable.ALL_COLUMNS, null /* where */,
                    null /* whereArgs */, null /* sortOrder */);

            return getLabelFromCursor(cursor);
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return null;
        } finally {
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Updates a single label.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param label The label with updated values to store.
     * @return {@code true} if the update succeeded, or {@code false} otherwise.
     */
    public boolean updateLabel(Label label) {
        LogUtils.log(this, Log.DEBUG, "Updating label: %s.", label);

        if (label == null) {
            return false;
        }

        if (!checkClient()) {
            return false;
        }

        final long labelId = label.getId();

        if (labelId == Label.NO_ID) {
            LogUtils.log(this, Log.WARN, "Cannot update label with no ID.");
            return false;
        }

        final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, labelId);
        final ContentValues values = buildContentValuesForLabel(label);

        try {
            final int rowsAffected = mClient.update(
                    uri, values, null /* selection */, null /* selectionArgs */);
            return rowsAffected > 0;
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return false;
        }
    }

    /**
     * Deletes a single label.
     * <p>
     * Don't run this method on the UI thread. Use {@link android.os.AsyncTask}.
     *
     * @param label The label to delete.
     * @return {@code true} if the delete succeeded, or {@code false} otherwise.
     */
    public boolean deleteLabel(Label label) {
        LogUtils.log(this, Log.DEBUG, "Deleting label: %s.", label);

        if (label == null) {
            return false;
        }

        if (!checkClient()) {
            return false;
        }

        final long labelId = label.getId();

        if (labelId == Label.NO_ID) {
            LogUtils.log(this, Log.WARN, "Cannot delete label with no ID.");
            return false;
        }

        final Uri uri = ContentUris.withAppendedId(mLabelsContentUri, labelId);

        try {
            final int rowsAffected = mClient.delete(
                    uri, null /* selection */, null /* selectionArgs */);
            return rowsAffected > 0;
        } catch (RemoteException e) {
            LogUtils.log(this, Log.ERROR, e.toString());
            return false;
        }
    }

    /**
     * Shuts down the client and releases any resources.
     */
    public void shutdown() {
        if (checkClient()) {
            mClient.release();
            mClient = null;
        }
    }

    /**
     * Returns whether the client was properly initialized (non-null).
     * @return {@code true} if client is non-null, or {@code false} otherwise.
     */
    public boolean isInitialized() {
        return mClient != null;
    }

    /**
     * Builds content values for the fields of a label.
     *
     * @param label The source of the values.
     * @return A set of values representing the label.
     */
    private static ContentValues buildContentValuesForLabel(Label label) {
        final ContentValues values = new ContentValues();

        values.put(LabelsTable.KEY_PACKAGE_NAME, label.getPackageName());
        values.put(LabelsTable.KEY_PACKAGE_SIGNATURE, label.getPackageSignature());
        values.put(LabelsTable.KEY_VIEW_NAME, label.getViewName());
        values.put(LabelsTable.KEY_TEXT, label.getText());
        values.put(LabelsTable.KEY_LOCALE, label.getLocale().toString());
        values.put(LabelsTable.KEY_PACKAGE_VERSION, label.getPackageVersion());
        values.put(LabelsTable.KEY_SCREENSHOT_PATH, label.getScreenshotPath());
        values.put(LabelsTable.KEY_TIMESTAMP, label.getTimestamp());

        return values;
    }

    /**
     * Gets a {@link Label} object from the data in the given cursor at the
     * current row position.
     *
     * @param cursor The cursor to use to get the label.
     * @return The label at the current cursor position,
     *         or {@code null} if the current cursor position has no row.
     */
    private Label getLabelFromCursorAtCurrentPosition(Cursor cursor) {
        if (cursor == null || cursor.isClosed() || cursor.isAfterLast()) {
            LogUtils.log(this, Log.WARN, "Failed to get label from cursor.");
            return null;
        }

        final long labelId = cursor.getLong(LabelsTable.INDEX_ID);
        final String packageName = cursor.getString(LabelsTable.INDEX_PACKAGE_NAME);
        final String packageSignature = cursor.getString(LabelsTable.INDEX_PACKAGE_SIGNATURE);
        final String viewName = cursor.getString(LabelsTable.INDEX_VIEW_NAME);
        final String text = cursor.getString(LabelsTable.INDEX_TEXT);
        final String locale = cursor.getString(LabelsTable.INDEX_LOCALE);
        final int packageVersion = cursor.getInt(LabelsTable.INDEX_PACKAGE_VERSION);
        final String screenshotPath = cursor.getString(LabelsTable.INDEX_SCREENSHOT_PATH);
        final long timestamp = cursor.getLong(LabelsTable.INDEX_TIMESTAMP);

        return new Label(labelId, packageName, packageSignature, viewName, text, locale,
                packageVersion, screenshotPath, timestamp);
    }

    /**
     * Gets a pair of package name and label count from the data in the given
     * cursor at the current row position.
     *
     * @param cursor The cursor to use to get the label.
     * @return A pair of package name and label count, or {@code null} if the
     *         current cursor position has no row.
     */
    private PackageLabelInfo getPackageLabelInfoFromCursor(Cursor cursor) {
        if (cursor == null || cursor.isClosed() || cursor.isAfterLast()) {
            LogUtils.log(this, Log.WARN, "Failed to get PackageLabelInfo from cursor.");
            return null;
        }

        final String packageName = cursor.getString(0);
        final int labelCount = cursor.getInt(1
                );

        return new PackageLabelInfo(packageName, labelCount);
    }

    /**
     * Gets a single label from a cursor as the result of a query.
     *
     * @param cursor The cursor from which to get the label.
     * @return The label returned from the query, or {@code null} if no valid
     *         label was returned.
     */
    private Label getLabelFromCursor(Cursor cursor) {
        if (cursor == null) {
            return null;
        }

        cursor.moveToFirst();
        final Label result = getLabelFromCursorAtCurrentPosition(cursor);

        logResult(result);

        return result;
    }

    /**
     * Gets an unmodifiable list of labels from a cursor resulting from a query.
     *
     * @param cursor The cursor from which to get the labels.
     * @return The unmodifiable list of labels returned from the query.
     */
    private List<Label> getLabelListFromCursor(Cursor cursor) {
        if (cursor == null) {
            return Collections.emptyList();
        }

    final List<Label> result = new ArrayList<Label>();
        while (cursor.moveToNext()) {
            final Label label = getLabelFromCursorAtCurrentPosition(cursor);
            if (label != null) {
                result.add(label);
            }
        }

        logResult(result);

        return Collections.unmodifiableList(result);
    }

    /**
     * Gets an unmodifiable list of {@link PackageLabelInfo} objects from a
     * cursor resulting from a query.
     *
     * @param cursor The cursor from which to get the package summary.
     * @return The unmodifiable list built from the query.
     */
    private List<PackageLabelInfo> getPackageSummaryFromCursor(Cursor cursor) {
        if (cursor == null) {
            return Collections.emptyList();
        }

    final List<PackageLabelInfo> result = new ArrayList<PackageLabelInfo>();
        while (cursor.moveToNext()) {
            final PackageLabelInfo packageLabelInfo = getPackageLabelInfoFromCursor(cursor);
            if (packageLabelInfo != null) {
                result.add(packageLabelInfo);
            }
        }

        return Collections.unmodifiableList(result);
    }

    /**
     * Gets an unmodifiable map of labels from a cursor resulting from a query.
     *
     * @param cursor The cursor from which to get the labels.
     * @return An unmodifiable map from view names to label objects containing
     *         all labels returned from the query.
     */
    private Map<String, Label> getLabelMapFromCursor(Cursor cursor) {
        if (cursor == null) {
            return Collections.emptyMap();
        }

        final int labelCount = cursor.getCount(); // can return -1
        final int initialCapacity = Math.max(labelCount, 0);

        final Map<String, Label> result = new HashMap<String, Label>(initialCapacity);
        while (cursor.moveToNext()) {
            final Label label = getLabelFromCursorAtCurrentPosition(cursor);
            if (label != null) {
                result.put(label.getViewName(), label);
            }
        }

        logResult(result.values());

        return Collections.unmodifiableMap(result);
    }

    /**
     * Logs a label resulting from a query.
     *
     * @param label The label to log.
     */
    private void logResult(Label label) {
        LogUtils.log(this, Log.VERBOSE, "Query result: %s.", label);
    }

    /**
     * Logs labels resulting from a query.
     *
     * @param result The labels to log.
     */
    private void logResult(Iterable<Label> result) {
        if (LogUtils.shouldLog(Log.VERBOSE)) {
            final StringBuilder logMessageBuilder = new StringBuilder("Query result: [");
            for (Label label : result) {
                logMessageBuilder.append("\n  ");
                logMessageBuilder.append(label);
            }
            logMessageBuilder.append("].");

            LogUtils.log(this, Log.VERBOSE, logMessageBuilder.toString());
        }
    }

    /**
     * Checks for a client and logs a warning if it is {@code null}.
     *
     * @return Whether the client is non-{@code null}.
     */
    private boolean checkClient() {
        if (mClient == null) {
            LogUtils.log(this, Log.WARN,
                    "Aborting operation: the client failed to initialize or already shut down.");
            return false;
        }

        return true;
    }
}