/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

package org.mozilla.gecko.db;

import android.annotation.TargetApi;
import android.database.DatabaseUtils;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteStatement;
import android.os.Build;
import org.mozilla.gecko.AppConstants;
import org.mozilla.gecko.GeckoAppShell;
import org.mozilla.gecko.GeckoProfile;

import android.content.ContentValues;
import android.database.Cursor;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.Uri;
import android.text.TextUtils;
import android.util.Log;
import org.mozilla.gecko.annotation.RobocopTarget;
import org.mozilla.gecko.Telemetry;

import java.util.Map;

public class DBUtils {
    private static final String LOGTAG = "GeckoDBUtils";

    public static final String qualifyColumn(String table, String column) {
        return table + "." + column;
    }

    // This is available in Android >= 11. Implemented locally to be
    // compatible with older versions.
    public static String concatenateWhere(String a, String b) {
        if (TextUtils.isEmpty(a)) {
            return b;
        }

        if (TextUtils.isEmpty(b)) {
            return a;
        }

        return "(" + a + ") AND (" + b + ")";
    }

    // This is available in Android >= 11. Implemented locally to be
    // compatible with older versions.
    public static String[] appendSelectionArgs(String[] originalValues, String[] newValues) {
        if (originalValues == null || originalValues.length == 0) {
            return newValues;
        }

        if (newValues == null || newValues.length == 0) {
            return originalValues;
        }

        String[] result = new String[originalValues.length + newValues.length];
        System.arraycopy(originalValues, 0, result, 0, originalValues.length);
        System.arraycopy(newValues, 0, result, originalValues.length, newValues.length);

        return result;
    }

    public static void replaceKey(ContentValues aValues, String aOriginalKey,
                                  String aNewKey, String aDefault) {
        String value = aDefault;
        if (aOriginalKey != null && aValues.containsKey(aOriginalKey)) {
            value = aValues.get(aOriginalKey).toString();
            aValues.remove(aOriginalKey);
        }

        if (!aValues.containsKey(aNewKey)) {
            aValues.put(aNewKey, value);
        }
    }

    private static String HISTOGRAM_DATABASE_LOCKED = "DATABASE_LOCKED_EXCEPTION";
    private static String HISTOGRAM_DATABASE_UNLOCKED = "DATABASE_SUCCESSFUL_UNLOCK";
    public static void ensureDatabaseIsNotLocked(SQLiteOpenHelper dbHelper, String databasePath) {
        final int maxAttempts = 5;
        int attempt = 0;
        SQLiteDatabase db = null;
        for (; attempt < maxAttempts; attempt++) {
            try {
                // Try a simple test and exit the loop.
                db = dbHelper.getWritableDatabase();
                break;
            } catch (Exception e) {
                // We assume that this is a android.database.sqlite.SQLiteDatabaseLockedException.
                // That class is only available on API 11+.
                Telemetry.addToHistogram(HISTOGRAM_DATABASE_LOCKED, attempt);

                // Things could get very bad if we don't find a way to unlock the DB.
                Log.d(LOGTAG, "Database is locked, trying to kill any zombie processes: " + databasePath);
                GeckoAppShell.killAnyZombies();
                try {
                    Thread.sleep(attempt * 100);
                } catch (InterruptedException ie) {
                }
            }
        }

        if (db == null) {
            Log.w(LOGTAG, "Failed to unlock database.");
            GeckoAppShell.listOfOpenFiles();
            return;
        }

        // If we needed to retry, but we succeeded, report that in telemetry.
        // Failures are indicated by a lower frequency of UNLOCKED than LOCKED.
        if (attempt > 1) {
            Telemetry.addToHistogram(HISTOGRAM_DATABASE_UNLOCKED, attempt - 1);
        }
    }

    /**
     * Copies a table <b>between</b> database files.
     *
     * This method assumes that the source table and destination table already exist in the
     * source and destination databases, respectively.
     *
     * The table is copied row-by-row in a single transaction.
     *
     * @param source The source database that the table will be copied from.
     * @param sourceTableName The name of the source table.
     * @param destination The destination database that the table will be copied to.
     * @param destinationTableName The name of the destination table.
     * @return true if all rows were copied; false otherwise.
     */
    public static boolean copyTable(SQLiteDatabase source, String sourceTableName,
                                    SQLiteDatabase destination, String destinationTableName) {
        Cursor cursor = null;
        try {
            destination.beginTransaction();

            cursor = source.query(sourceTableName, null, null, null, null, null, null);
            Log.d(LOGTAG, "Trying to copy " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName);

            final ContentValues contentValues = new ContentValues();
            while (cursor.moveToNext()) {
                contentValues.clear();
                DatabaseUtils.cursorRowToContentValues(cursor, contentValues);
                destination.insert(destinationTableName, null, contentValues);
            }

            destination.setTransactionSuccessful();
            Log.d(LOGTAG, "Successfully copied " + cursor.getCount() + " rows from " + sourceTableName + " to " + destinationTableName);
            return true;
        } catch (Exception e) {
            Log.w(LOGTAG, "Got exception copying rows from " + sourceTableName + " to " + destinationTableName + "; ignoring.", e);
            return false;
        } finally {
            destination.endTransaction();
            if (cursor != null) {
                cursor.close();
            }
        }
    }

    /**
     * Verifies that 0-byte arrays aren't added as favicon or thumbnail data.
     * @param values        ContentValues of query
     * @param columnName    Name of data column to verify
     */
    public static void stripEmptyByteArray(ContentValues values, String columnName) {
        if (values.containsKey(columnName)) {
            byte[] data = values.getAsByteArray(columnName);
            if (data == null || data.length == 0) {
                Log.w(LOGTAG, "Tried to insert an empty or non-byte-array image. Ignoring.");
                values.putNull(columnName);
            }
        }
    }

    /**
     * Builds a selection string that searches for a list of arguments in a particular column.
     * For example URL in (?,?,?). Callers should pass the actual arguments into their query
     * as selection args.
     * @para columnName   The column to search in
     * @para size         The number of arguments to search for
     */
    public static String computeSQLInClause(int items, String field) {
        final StringBuilder builder = new StringBuilder(field);
        builder.append(" IN (");
        int i = 0;
        for (; i < items - 1; ++i) {
            builder.append("?, ");
        }
        if (i < items) {
            builder.append("?");
        }
        builder.append(")");
        return builder.toString();
    }

    /**
     * Turn a single-column cursor of longs into a single SQL "IN" clause.
     * We can do this without using selection arguments because Long isn't
     * vulnerable to injection.
     */
    public static String computeSQLInClauseFromLongs(final Cursor cursor, String field) {
        final StringBuilder builder = new StringBuilder(field);
        builder.append(" IN (");
        final int commaLimit = cursor.getCount() - 1;
        int i = 0;
        while (cursor.moveToNext()) {
            builder.append(cursor.getLong(0));
            if (i++ < commaLimit) {
                builder.append(", ");
            }
        }
        builder.append(")");
        return builder.toString();
    }

    public static Uri appendProfile(final String profile, final Uri uri) {
        return uri.buildUpon().appendQueryParameter(BrowserContract.PARAM_PROFILE, profile).build();
    }

    public static Uri appendProfileWithDefault(final String profile, final Uri uri) {
        if (TextUtils.isEmpty(profile)) {
            return appendProfile(GeckoProfile.DEFAULT_PROFILE, uri);
        }
        return appendProfile(profile, uri);
    }

    /**
     * Use the following when no conflict action is specified.
     */
    private static final int CONFLICT_NONE = 0;
    private static final String[] CONFLICT_VALUES = new String[] {"", " OR ROLLBACK ", " OR ABORT ", " OR FAIL ", " OR IGNORE ", " OR REPLACE "};

    /**
     * Convenience method for updating rows in the database.
     *
     * @param table the table to update in
     * @param values a map from column names to new column values. null is a
     *            valid value that will be translated to NULL.
     * @param whereClause the optional WHERE clause to apply when updating.
     *            Passing null will update all rows.
     * @param whereArgs You may include ?s in the where clause, which
     *            will be replaced by the values from whereArgs. The values
     *            will be bound as Strings.
     * @return the number of rows affected
     */
    @RobocopTarget
    public static int updateArrays(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) {
        return updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, true);
    }

    public static void updateArraysBlindly(SQLiteDatabase db, String table, ContentValues[] values, UpdateOperation[] ops, String whereClause, String[] whereArgs) {
        updateArraysWithOnConflict(db, table, values, ops, whereClause, whereArgs, CONFLICT_NONE, false);
    }

    @RobocopTarget
    public enum UpdateOperation {
        /**
         * ASSIGN is the usual update: replaces the value in the named column with the provided value.
         *
         * foo = ?
         */
        ASSIGN,

        /**
         * BITWISE_OR applies the provided value to the existing value with a bitwise OR. This is useful for adding to flags.
         *
         * foo |= ?
         */
        BITWISE_OR,

        /**
         * EXPRESSION is an end-run around the API: it allows callers to specify a fragment of SQL to splice into the
         * SET part of the query.
         *
         * foo = $value
         *
         * Be very careful not to use user input in this.
         */
        EXPRESSION,
    }

    /**
     * This is an evil reimplementation of SQLiteDatabase's methods to allow for
     * smarter updating.
     *
     * Each ContentValues has an associated enum that describes how to unify input values with the existing column values.
     */
    private static int updateArraysWithOnConflict(SQLiteDatabase db, String table,
                                          ContentValues[] values,
                                          UpdateOperation[] ops,
                                          String whereClause,
                                          String[] whereArgs,
                                          int conflictAlgorithm,
                                          boolean returnChangedRows) {
        if (values == null || values.length == 0) {
            throw new IllegalArgumentException("Empty values");
        }

        if (ops == null || ops.length != values.length) {
            throw new IllegalArgumentException("ops and values don't match");
        }

        StringBuilder sql = new StringBuilder(120);
        sql.append("UPDATE ");
        sql.append(CONFLICT_VALUES[conflictAlgorithm]);
        sql.append(table);
        sql.append(" SET ");

        // move all bind args to one array
        int setValuesSize = 0;
        for (int i = 0; i < values.length; i++) {
            // EXPRESSION types don't contribute any placeholders.
            if (ops[i] != UpdateOperation.EXPRESSION) {
                setValuesSize += values[i].size();
            }
        }

        int bindArgsSize = (whereArgs == null) ? setValuesSize : (setValuesSize + whereArgs.length);
        Object[] bindArgs = new Object[bindArgsSize];

        int arg = 0;
        for (int i = 0; i < values.length; i++) {
            final ContentValues v = values[i];
            final UpdateOperation op = ops[i];

            // Alas, code duplication.
            switch (op) {
                case ASSIGN:
                    for (Map.Entry<String, Object> entry : v.valueSet()) {
                        final String colName = entry.getKey();
                        sql.append((arg > 0) ? "," : "");
                        sql.append(colName);
                        bindArgs[arg++] = entry.getValue();
                        sql.append("= ?");
                    }
                    break;
                case BITWISE_OR:
                    for (Map.Entry<String, Object> entry : v.valueSet()) {
                        final String colName = entry.getKey();
                        sql.append((arg > 0) ? "," : "");
                        sql.append(colName);
                        bindArgs[arg++] = entry.getValue();
                        sql.append("= ? | ");
                        sql.append(colName);
                    }
                    break;
                case EXPRESSION:
                    // Treat each value as a literal SQL string.
                    for (Map.Entry<String, Object> entry : v.valueSet()) {
                        final String colName = entry.getKey();
                        sql.append((arg > 0) ? "," : "");
                        sql.append(colName);
                        sql.append(" = ");
                        sql.append(entry.getValue());
                    }
                    break;
            }
        }

        if (whereArgs != null) {
            for (arg = setValuesSize; arg < bindArgsSize; arg++) {
                bindArgs[arg] = whereArgs[arg - setValuesSize];
            }
        }
        if (!TextUtils.isEmpty(whereClause)) {
            sql.append(" WHERE ");
            sql.append(whereClause);
        }

        // What a huge pain in the ass, all because SQLiteDatabase doesn't expose .executeSql,
        // and we can't get a DB handle. Nor can we easily construct a statement with arguments
        // already bound.
        final SQLiteStatement statement = db.compileStatement(sql.toString());
        try {
            bindAllArgs(statement, bindArgs);
            if (!returnChangedRows) {
                statement.execute();
                return 0;
            }

            if (AppConstants.Versions.feature11Plus) {
                // This is a separate method so we can annotate it with @TargetApi.
                return executeStatementReturningChangedRows(statement);
            } else {
                statement.execute();
                final Cursor cursor = db.rawQuery("SELECT changes()", null);
                try {
                    cursor.moveToFirst();
                    return cursor.getInt(0);
                } finally {
                    cursor.close();
                }

            }
        } finally {
            statement.close();
        }
    }

    @TargetApi(Build.VERSION_CODES.HONEYCOMB)
    private static int executeStatementReturningChangedRows(SQLiteStatement statement) {
        return statement.executeUpdateDelete();
    }

    // All because {@link SQLiteProgram#bind(integer, Object)} is private.
    private static void bindAllArgs(SQLiteStatement statement, Object[] bindArgs) {
        if (bindArgs == null) {
            return;
        }
        for (int i = bindArgs.length; i != 0; i--) {
            Object v = bindArgs[i - 1];
            if (v == null) {
                statement.bindNull(i);
            } else if (v instanceof String) {
                statement.bindString(i, (String) v);
            } else if (v instanceof Double) {
                statement.bindDouble(i, (Double) v);
            } else if (v instanceof Float) {
                statement.bindDouble(i, (Float) v);
            } else if (v instanceof Long) {
                statement.bindLong(i, (Long) v);
            } else if (v instanceof Integer) {
                statement.bindLong(i, (Integer) v);
            } else if (v instanceof Byte) {
                statement.bindLong(i, (Byte) v);
            } else if (v instanceof byte[]) {
                statement.bindBlob(i, (byte[]) v);
            }
        }
    }
}
