/*******************************************************************************
 * Copyright (c) 2010 Denis Solonenko.
 * All rights reserved. This program and the accompanying materials
 * are made available under the terms of the GNU Public License v2.0
 * which accompanies this distribution, and is available at
 * http://www.gnu.org/licenses/old-licenses/gpl-2.0.html
 *
 * Contributors:
 *     Denis Solonenko - initial API and implementation
 *     Abdsandryk - implement getAllExpenses method for bill filtering
 ******************************************************************************/
package ru.orangesoftware.financisto.db;

import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.util.Log;
import org.androidannotations.annotations.EBean;
import ru.orangesoftware.financisto.R;
import ru.orangesoftware.financisto.blotter.BlotterFilter;
import ru.orangesoftware.financisto.datetime.DateUtils;
import ru.orangesoftware.financisto.db.DatabaseHelper.*;
import ru.orangesoftware.financisto.filter.Criteria;
import ru.orangesoftware.financisto.filter.WhereFilter;
import ru.orangesoftware.financisto.model.*;
import ru.orangesoftware.financisto.model.Currency;
import ru.orangesoftware.financisto.rates.*;
import ru.orangesoftware.financisto.utils.ArrUtils;
import ru.orangesoftware.financisto.utils.StringUtil;

import java.math.BigDecimal;
import java.util.*;

import static ru.orangesoftware.financisto.db.DatabaseHelper.*;
import static ru.orangesoftware.financisto.db.DatabaseHelper.SmsTemplateColumns.*;
import static ru.orangesoftware.financisto.utils.StringUtil.generateQueryPlaceholders;

@EBean(scope = EBean.Scope.Singleton)
public class DatabaseAdapter extends MyEntityManager {

    private boolean updateAccountBalance = true;

    public DatabaseAdapter(Context context) {
        super(context);
    }

    public void open() {
    }

    public void close() {
    }

    // ===================================================================
    // ACCOUNT
    // ===================================================================

    private static final String UPDATE_ORPHAN_TRANSACTIONS_1 = "UPDATE " + TRANSACTION_TABLE + " SET " +
            TransactionColumns.to_account_id + "=0, " +
            TransactionColumns.to_amount + "=0 " +
            "WHERE " + TransactionColumns.to_account_id + "=?";
    private static final String UPDATE_ORPHAN_TRANSACTIONS_2 = "UPDATE " + TRANSACTION_TABLE + " SET " +
            TransactionColumns.from_account_id + "=" + TransactionColumns.to_account_id + ", " +
            TransactionColumns.from_amount + "=" + TransactionColumns.to_amount + ", " +
            TransactionColumns.to_account_id + "=0, " +
            TransactionColumns.to_amount + "=0, " +
            TransactionColumns.parent_id + "=0 " +
            "WHERE " + TransactionColumns.from_account_id + "=? AND " +
            TransactionColumns.to_account_id + ">0";

    public int deleteAccount(long id) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            String[] sid = new String[]{String.valueOf(id)};
            db.execSQL(UPDATE_ORPHAN_TRANSACTIONS_1, sid);
            db.execSQL(UPDATE_ORPHAN_TRANSACTIONS_2, sid);
            db.delete(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.TRANSACTION_ID
                    + " in (SELECT _id from " + TRANSACTION_TABLE + " where " + TransactionColumns.from_account_id + "=?)", sid);
            db.delete(TRANSACTION_TABLE, TransactionColumns.from_account_id + "=?", sid);
            int count = db.delete(ACCOUNT_TABLE, "_id=?", sid);
            db.setTransactionSuccessful();
            return count;
        } finally {
            db.endTransaction();
        }

    }

    // ===================================================================
    // TRANSACTION
    // ===================================================================

    public Transaction getTransaction(long id) {
        Transaction t = get(Transaction.class, id);
        if (t != null) {
            t.systemAttributes = getSystemAttributesForTransaction(id);
            if (t.isSplitParent()) {
                t.splits = getSplitsForTransaction(t.id);
            }
            return t;
        }
        return new Transaction();
    }

    public Cursor getBlotter(WhereFilter filter) {
        String view = filter.isEmpty() ? V_BLOTTER : V_BLOTTER_FLAT_SPLITS;
        return getBlotter(view, filter);
    }

    public Cursor getBlotterForAccount(WhereFilter filter) {
        WhereFilter accountFilter = enhanceFilterForAccountBlotter(filter);
        return getBlotter(V_BLOTTER_FOR_ACCOUNT_WITH_SPLITS, accountFilter);
    }

    public static WhereFilter enhanceFilterForAccountBlotter(WhereFilter filter) {
        WhereFilter accountFilter = WhereFilter.copyOf(filter);
        accountFilter.put(Criteria.raw(BlotterColumns.parent_id + "=0 OR " + BlotterColumns.is_transfer + "=-1"));
        return accountFilter;
    }

    public Cursor getBlotterForAccountWithSplits(WhereFilter filter) {
        return getBlotter(V_BLOTTER_FOR_ACCOUNT_WITH_SPLITS, filter);
    }

    private Cursor getBlotter(String view, WhereFilter filter) {
        long t0 = System.currentTimeMillis();
        try {
            String sortOrder = getBlotterSortOrder(filter);
            return db().query(view, BlotterColumns.NORMAL_PROJECTION,
                    filter.getSelection(), filter.getSelectionArgs(), null, null,
                    sortOrder);
        } finally {
            long t1 = System.currentTimeMillis();
            Log.i("DB", "getBlotter " + (t1 - t0) + "ms");
        }
    }

    private String getBlotterSortOrder(WhereFilter filter) {
        String sortOrder = filter.getSortOrder();
        if (sortOrder == null || sortOrder.length() == 0) {
            sortOrder = BlotterFilter.SORT_NEWER_TO_OLDER + "," + BlotterFilter.SORT_NEWER_TO_OLDER_BY_ID;
        } else {
            if (sortOrder.contains(BlotterFilter.SORT_NEWER_TO_OLDER)) {
                sortOrder += "," + BlotterFilter.SORT_NEWER_TO_OLDER_BY_ID;
            } else {
                sortOrder += "," + BlotterFilter.SORT_OLDER_TO_NEWER_BY_ID;
            }
        }
        return sortOrder;
    }

    public Cursor getAllTemplates(WhereFilter filter, String sortBy) {
        long t0 = System.currentTimeMillis();
        try {
            return db().query(V_ALL_TRANSACTIONS, BlotterColumns.NORMAL_PROJECTION,
                    filter.getSelection(), filter.getSelectionArgs(), null, null,
                    sortBy);
        } finally {
            long t1 = System.currentTimeMillis();
            Log.i("DB", "getBlotter " + (t1 - t0) + "ms");
        }
    }

    public Cursor getBlotterWithSplits(String where) {
        return db().query(V_BLOTTER_FOR_ACCOUNT_WITH_SPLITS, BlotterColumns.NORMAL_PROJECTION, where, null, null, null,
                BlotterColumns.datetime + " DESC");
    }

    private static final String LOCATION_COUNT_UPDATE = "UPDATE " + LOCATIONS_TABLE
            + " SET count=count+(?) WHERE _id=?";

    private void updateLocationCount(long locationId, int count) {
        db().execSQL(LOCATION_COUNT_UPDATE, new Object[]{count, locationId});
    }

    private static final String ACCOUNT_LAST_CATEGORY_UPDATE = "UPDATE " + ACCOUNT_TABLE
            + " SET " + AccountColumns.LAST_CATEGORY_ID + "=? "
            + " WHERE " + AccountColumns.ID + "=?";

    private static final String ACCOUNT_LAST_ACCOUNT_UPDATE = "UPDATE " + ACCOUNT_TABLE
            + " SET " + AccountColumns.LAST_ACCOUNT_ID + "=? "
            + " WHERE " + AccountColumns.ID + "=?";

    private static final String PAYEE_LAST_CATEGORY_UPDATE = "UPDATE " + PAYEE_TABLE
            + " SET last_category_id=(?) WHERE _id=?";

    private static final String CATEGORY_LAST_LOCATION_UPDATE = "UPDATE " + CATEGORY_TABLE
            + " SET last_location_id=(?) WHERE _id=?";

    private static final String CATEGORY_LAST_PROJECT_UPDATE = "UPDATE " + CATEGORY_TABLE
            + " SET last_project_id=(?) WHERE _id=?";

    private void updateLastUsed(Transaction t) {
        SQLiteDatabase db = db();
        if (t.isTransfer()) {
            db.execSQL(ACCOUNT_LAST_ACCOUNT_UPDATE, new Object[]{t.toAccountId, t.fromAccountId});
        }
        db.execSQL(ACCOUNT_LAST_CATEGORY_UPDATE, new Object[]{t.categoryId, t.fromAccountId});
        db.execSQL(PAYEE_LAST_CATEGORY_UPDATE, new Object[]{t.categoryId, t.payeeId});
        db.execSQL(CATEGORY_LAST_LOCATION_UPDATE, new Object[]{t.locationId, t.categoryId});
        db.execSQL(CATEGORY_LAST_PROJECT_UPDATE, new Object[]{t.projectId, t.categoryId});
    }

    public long duplicateTransaction(long id) {
        return duplicateTransaction(id, 0, 1);
    }

    public long duplicateTransactionWithMultiplier(long id, int multiplier) {
        return duplicateTransaction(id, 0, multiplier);
    }

    public long duplicateTransactionAsTemplate(long id) {
        return duplicateTransaction(id, 1, 1);
    }

    private long duplicateTransaction(long id, int isTemplate, int multiplier) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            long now = System.currentTimeMillis();
            Transaction transaction = getTransaction(id);
            if (transaction.isSplitChild()) {
                id = transaction.parentId;
                transaction = getTransaction(id);
            }
            transaction.lastRecurrence = now;
            updateTransaction(transaction);
            transaction.id = -1;
            transaction.isTemplate = isTemplate;
            transaction.dateTime = now;
            transaction.remoteKey = null;
            if (isTemplate == 0) {
                transaction.recurrence = null;
                transaction.notificationOptions = null;
            }
            if (multiplier > 1) {
                transaction.fromAmount *= multiplier;
                transaction.toAmount *= multiplier;
            }
            long transactionId = insertTransaction(transaction);
            Map<Long, String> attributesMap = getAllAttributesForTransaction(id);
            LinkedList<TransactionAttribute> attributes = new LinkedList<TransactionAttribute>();
            for (long attributeId : attributesMap.keySet()) {
                TransactionAttribute ta = new TransactionAttribute();
                ta.attributeId = attributeId;
                ta.value = attributesMap.get(attributeId);
                attributes.add(ta);
            }
            if (attributes.size() > 0) {
                insertAttributes(transactionId, attributes);
            }
            List<Transaction> splits = getSplitsForTransaction(id);
            if (multiplier > 1) {
                for (Transaction split : splits) {
                    split.fromAmount *= multiplier;
                    split.remoteKey = null;
                }
            }
            transaction.id = transactionId;
            transaction.splits = splits;
            insertSplits(transaction);
            db.setTransactionSuccessful();
            return transactionId;
        } finally {
            db.endTransaction();
        }
    }

    public long insertOrUpdate(Transaction transaction) {
        return insertOrUpdate(transaction, Collections.<TransactionAttribute>emptyList());
    }

    public long insertOrUpdate(Transaction transaction, List<TransactionAttribute> attributes) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            long id = insertOrUpdateInTransaction(transaction, attributes);
            db.setTransactionSuccessful();
            return id;
        } finally {
            db.endTransaction();
        }
    }

    public long insertOrUpdateInTransaction(Transaction transaction, List<TransactionAttribute> attributes) {
        long transactionId;
        transaction.lastRecurrence = System.currentTimeMillis();
        if (transaction.id == -1) {
            transactionId = insertTransaction(transaction);
        } else {
            updateTransaction(transaction);
            transactionId = transaction.id;
            db().delete(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.TRANSACTION_ID + "=?",
                    new String[]{String.valueOf(transactionId)});
            deleteSplitsForParentTransaction(transactionId);
        }
        if (attributes != null) {
            insertAttributes(transactionId, attributes);
        }
        transaction.id = transactionId;
        insertSplits(transaction);
        updateAccountLastTransactionDate(transaction.fromAccountId);
        updateAccountLastTransactionDate(transaction.toAccountId);
        return transactionId;
    }

    public void insertWithoutUpdatingBalance(Transaction transaction) {
        updateAccountBalance = false;
        try {
            transaction.id = insertTransaction(transaction);
            insertSplits(transaction);
        } finally {
            updateAccountBalance = true;
        }
    }

    private void insertAttributes(long transactionId, List<TransactionAttribute> attributes) {
        for (TransactionAttribute a : attributes) {
            a.transactionId = transactionId;
            ContentValues values = a.toValues();
            db().insert(TRANSACTION_ATTRIBUTE_TABLE, null, values);
        }
    }

    private void insertAttributes(long transactionId, Map<Long, String> categoryAttributes) {
        if (categoryAttributes != null && categoryAttributes.size() > 0) {
            List<TransactionAttribute> attributes = new LinkedList<TransactionAttribute>();
            for (Map.Entry<Long, String> e : categoryAttributes.entrySet()) {
                TransactionAttribute a = new TransactionAttribute();
                a.attributeId = e.getKey();
                a.value = e.getValue();
                attributes.add(a);
            }
            insertAttributes(transactionId, attributes);
        }
    }

    private void insertSplits(Transaction parent) {
        List<Transaction> splits = parent.splits;
        if (splits != null) {
            for (Transaction split : splits) {
                split.id = -1;
                split.parentId = parent.id;
                split.dateTime = parent.dateTime;
                split.fromAccountId = parent.fromAccountId;
                split.payeeId = parent.payeeId;
                split.isTemplate = parent.isTemplate;
                split.status = parent.status;
                updateSplitOriginalAmount(parent, split);
                long splitId = insertTransaction(split);
                insertAttributes(splitId, split.categoryAttributes);
            }
        }
    }

    private void updateSplitOriginalAmount(Transaction parent, Transaction split) {
        if (parent.originalCurrencyId > 0) {
            split.originalCurrencyId = parent.originalCurrencyId;
            split.originalFromAmount = split.fromAmount;
            split.fromAmount = calculateAmountInAccountCurrency(parent, split.fromAmount);
        }
    }

    private long calculateAmountInAccountCurrency(Transaction parent, long amount) {
        double rate = getRateFromParent(parent);
        return (long) (rate * amount);
    }

    private double getRateFromParent(Transaction parent) {
        if (parent.originalFromAmount != 0) {
            return Math.abs(1.0 * parent.fromAmount / parent.originalFromAmount);
        }
        return 0;
    }

    private long insertTransaction(Transaction t) {
        t.updatedOn = System.currentTimeMillis();
        long id = db().insert(TRANSACTION_TABLE, null, t.toValues());
        if (updateAccountBalance) {
            if (!t.isTemplateLike()) {
                if (t.isSplitChild()) {
                    if (t.isTransfer()) {
                        updateToAccountBalance(t, id);
                    }
                } else {
                    updateFromAccountBalance(t, id);
                    updateToAccountBalance(t, id);
                    updateLocationCount(t.locationId, 1);
                    updateLastUsed(t);
                }
            }
        }
        return id;
    }

    private void updateFromAccountBalance(Transaction t, long id) {
        updateAccountBalance(t.fromAccountId, t.fromAmount);
        insertRunningBalance(t.fromAccountId, id, t.dateTime, t.fromAmount, t.fromAmount);
    }

    private void updateToAccountBalance(Transaction t, long id) {
        updateAccountBalance(t.toAccountId, t.toAmount);
        insertRunningBalance(t.toAccountId, id, t.dateTime, t.toAmount, t.toAmount);
    }

    private void updateTransaction(Transaction t) {
        Transaction oldT = null;
        if (t.isNotTemplateLike()) {
            oldT = getTransaction(t.id);
            updateAccountBalance(oldT.fromAccountId, oldT.fromAmount, t.fromAccountId, t.fromAmount);
            updateAccountBalance(oldT.toAccountId, oldT.toAmount, t.toAccountId, t.toAmount);
            updateRunningBalance(oldT, t);
            if (oldT.locationId != t.locationId) {
                updateLocationCount(oldT.locationId, -1);
                updateLocationCount(t.locationId, 1);
            }
        }
        t.updatedOn = System.currentTimeMillis();
        db().update(TRANSACTION_TABLE, t.toValues(), TransactionColumns._id + "=?",
                new String[]{String.valueOf(t.id)});
        if (oldT != null) {
            updateAccountLastTransactionDate(oldT.fromAccountId);
            updateAccountLastTransactionDate(oldT.toAccountId);
        }
    }

    public void updateTransactionStatus(long id, TransactionStatus status) {
        Transaction t = getTransaction(id);
        t.status = status;
        updateTransaction(t);
    }

    public void deleteTransaction(long id) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            deleteTransactionNoDbTransaction(id);
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    public void deleteTransactionNoDbTransaction(long id) {
        Transaction t = getTransaction(id);
        if (t.isNotTemplateLike()) {
            revertFromAccountBalance(t);
            revertToAccountBalance(t);
            updateAccountLastTransactionDate(t.fromAccountId);
            updateAccountLastTransactionDate(t.toAccountId);
            updateLocationCount(t.locationId, -1);
        }
        String[] sid = new String[]{String.valueOf(id)};
        SQLiteDatabase db = db();
        db.delete(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.TRANSACTION_ID + "=?", sid);
        db.delete(TRANSACTION_TABLE, TransactionColumns._id + "=?", sid);
        deleteSplitsForParentTransaction(id);
    }

    private void deleteSplitsForParentTransaction(long parentId) {
        List<Transaction> splits = getSplitsForTransaction(parentId);
        SQLiteDatabase db = db();
        for (Transaction split : splits) {
            if (split.isTransfer()) {
                revertToAccountBalance(split);
            }
            db.delete(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.TRANSACTION_ID + "=?",
                    new String[]{String.valueOf(split.id)});
        }

        db.delete(TRANSACTION_TABLE, TransactionColumns.parent_id + "=?", new String[]{String.valueOf(parentId)});

    }

    private void revertFromAccountBalance(Transaction t) {
        updateAccountBalance(t.fromAccountId, -t.fromAmount);
        deleteRunningBalance(t.fromAccountId, t.id, t.fromAmount, t.dateTime);
    }

    private void revertToAccountBalance(Transaction t) {
        updateAccountBalance(t.toAccountId, -t.toAmount);
        deleteRunningBalance(t.toAccountId, t.id, t.toAmount, t.dateTime);
    }

    private void updateAccountBalance(long oldAccountId, long oldAmount, long newAccountId, long newAmount) {
        if (oldAccountId == newAccountId) {
            updateAccountBalance(newAccountId, newAmount - oldAmount);
        } else {
            updateAccountBalance(oldAccountId, -oldAmount);
            updateAccountBalance(newAccountId, newAmount);
        }
    }

    private static final String ACCOUNT_TOTAL_AMOUNT_UPDATE = "UPDATE " + ACCOUNT_TABLE
            + " SET " + AccountColumns.TOTAL_AMOUNT + "=" + AccountColumns.TOTAL_AMOUNT + "+(?) "
            + " WHERE " + AccountColumns.ID + "=?";

    private void updateAccountBalance(long accountId, long deltaAmount) {
        if (accountId <= 0) {
            return;
        }
        db().execSQL(ACCOUNT_TOTAL_AMOUNT_UPDATE, new Object[]{deltaAmount, accountId});
    }

    private static final String INSERT_RUNNING_BALANCE =
            "insert or replace into running_balance(account_id,transaction_id,datetime,balance) values (?,?,?,?)";

    private static final String UPDATE_RUNNING_BALANCE =
            "update running_balance set balance = balance+(?) where account_id = ? and datetime > ?";

    private static final String DELETE_RUNNING_BALANCE =
            "delete from running_balance where account_id = ? and transaction_id = ?";

    private void insertRunningBalance(long accountId, long transactionId, long datetime, long amount, long deltaAmount) {
        if (accountId <= 0) {
            return;
        }
        long previousTransactionBalance = fetchAccountBalanceAtTheTime(accountId, datetime);
        SQLiteDatabase db = db();
        db.execSQL(INSERT_RUNNING_BALANCE, new Object[]{accountId, transactionId, datetime, previousTransactionBalance + amount});
        db.execSQL(UPDATE_RUNNING_BALANCE, new Object[]{deltaAmount, accountId, datetime});
    }

    private void updateRunningBalance(Transaction oldTransaction, Transaction newTransaction) {
        deleteRunningBalance(oldTransaction.fromAccountId, oldTransaction.id, oldTransaction.fromAmount, oldTransaction.dateTime);
        insertRunningBalance(newTransaction.fromAccountId, newTransaction.id, newTransaction.dateTime,
                newTransaction.fromAmount, newTransaction.fromAmount);
        deleteRunningBalance(oldTransaction.toAccountId, oldTransaction.id, oldTransaction.toAmount, oldTransaction.dateTime);
        insertRunningBalance(newTransaction.toAccountId, newTransaction.id, newTransaction.dateTime,
                newTransaction.toAmount, newTransaction.toAmount);
    }

    private void deleteRunningBalance(long accountId, long transactionId, long amount, long dateTime) {
        if (accountId <= 0) {
            return;
        }
        SQLiteDatabase db = db();
        db.execSQL(DELETE_RUNNING_BALANCE, new Object[]{accountId, transactionId});
        db.execSQL(UPDATE_RUNNING_BALANCE, new Object[]{-amount, accountId, dateTime});
    }

    private long fetchAccountBalanceAtTheTime(long accountId, long datetime) {
        return DatabaseUtils.rawFetchLongValue(this, "select balance from running_balance where account_id = ? and datetime <= ? order by datetime desc, transaction_id desc limit 1",
                new String[]{String.valueOf(accountId), String.valueOf(datetime)});
    }

    // ===================================================================
    // CATEGORY
    // ===================================================================

    public long insertOrUpdate(Category category, List<Attribute> attributes) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            long id;
            if (category.id == -1) {
                id = insertCategory(category);
            } else {
                updateCategory(category);
                id = category.id;
            }
            addAttributes(id, attributes);
            category.id = id;
            db.setTransactionSuccessful();
            return id;
        } finally {
            db.endTransaction();
        }
    }

    private void addAttributes(long categoryId, List<Attribute> attributes) {
        SQLiteDatabase db = db();
        db.delete(CATEGORY_ATTRIBUTE_TABLE, CategoryAttributeColumns.CATEGORY_ID + "=?", new String[]{String.valueOf(categoryId)});
        if (attributes != null) {
            ContentValues values = new ContentValues();
            values.put(CategoryAttributeColumns.CATEGORY_ID, categoryId);
            for (Attribute a : attributes) {
                values.put(CategoryAttributeColumns.ATTRIBUTE_ID, a.id);
                db.insert(CATEGORY_ATTRIBUTE_TABLE, null, values);
            }
        }
    }

    private long insertCategory(Category category) {
        CategoryTree<Category> tree = getCategoriesTree(false);
        long parentId = category.getParentId();
        if (parentId == Category.NO_CATEGORY_ID) {
            if (!tree.isEmpty()) {
                return insertAsLast(category, tree);
            }
        } else {
            Map<Long, Category> map = tree.asMap();
            Category parent = map.get(parentId);
            if (parent != null && parent.hasChildren()) {
                CategoryTree<Category> children = parent.children;
                return insertAsLast(category, children);
            }
        }
        return insertChildCategory(parentId, category);
    }

    private long insertAsLast(Category category, CategoryTree<Category> tree) {
        long mateId = tree.getAt(tree.size() - 1).id;
        return insertMateCategory(mateId, category);
    }

    private long updateCategory(Category category) {
        Category oldCategory = getCategoryWithParent(category.id);
        if (oldCategory.getParentId() == category.getParentId()) {
            updateCategory(category.id, category.title, category.type);
            updateChildCategoriesType(category.type, category.left, category.right);
        } else {
            moveCategory(category);
        }
        return category.id;
    }

    private void moveCategory(Category category) {
        CategoryTree<Category> tree = getCategoriesTree(false);
        Map<Long, Category> map = tree.asMap();
        Category oldCategory = map.get(category.id);
        if (oldCategory != null) {
            Category oldParent = map.get(oldCategory.getParentId());
            if (oldParent != null) {
                oldParent.removeChild(oldCategory);
            } else {
                tree.remove(oldCategory);
            }
            Category newParent = map.get(category.getParentId());
            int newCategoryType = category.type;
            if (newParent != null) {
                newParent.addChild(oldCategory);
                newCategoryType = newParent.type;
            } else {
                tree.add(oldCategory);
            }
            tree.reIndex();
            updateCategoryTreeInTransaction(tree);
            updateCategory(category.id, category.title, newCategoryType);
            updateChildCategoriesType(newCategoryType, oldCategory.left, oldCategory.right);
        }
    }


    private static final String GET_PARENT_SQL = "(SELECT "
            + "parent." + CategoryColumns._id + " AS " + CategoryColumns._id
            + " FROM "
            + CATEGORY_TABLE + " AS node" + ","
            + CATEGORY_TABLE + " AS parent "
            + " WHERE "
            + " node." + CategoryColumns.left + " BETWEEN parent." + CategoryColumns.left + " AND parent." + CategoryColumns.right
            + " AND node." + CategoryColumns._id + "=?"
            + " AND parent." + CategoryColumns._id + "!=?"
            + " ORDER BY parent." + CategoryColumns.left + " DESC)";

    public Category getCategoryWithParent(long id) {
        SQLiteDatabase db = db();
        try (Cursor c = db.query(V_CATEGORY, CategoryViewColumns.NORMAL_PROJECTION,
            CategoryViewColumns._id + "=?", new String[]{String.valueOf(id)}, null, null, null)) {
            if (c.moveToNext()) {
                Category cat = Category.formCursor(c);
                String s = String.valueOf(id);
                try (Cursor c2 = db.query(GET_PARENT_SQL, new String[]{CategoryColumns._id.name()}, null, new String[]{s, s}, null, null, null, "1")) {
                    if (c2.moveToFirst()) {
                        cat.parent = new Category(c2.getLong(0));
                    }
                }
                return cat;
            } else {
                return new Category(-1);
            }
        }
    }

    public List<Long> getCategoryIdsByLeftIds(List<String> leftIds) {
        SQLiteDatabase db = db();
        List<Long> res = new LinkedList<>();
        try (Cursor c = db.query(V_CATEGORY, new String[]{CategoryViewColumns._id.name()},
                CategoryViewColumns.left + " IN (" + generateQueryPlaceholders(leftIds.size()) + ")",
                ArrUtils.strListToArr(leftIds), null, null, null)) {
            while (c.moveToNext()) {
                res.add(c.getLong(0));
            }
        }
        return res;
    }
    
    public Category getCategoryByLeft(long left) {
        SQLiteDatabase db = db();
        try (Cursor c = db.query(V_CATEGORY, CategoryViewColumns.NORMAL_PROJECTION,
            CategoryViewColumns.left + "=?", new String[]{String.valueOf(left)}, null, null, null)) {
            if (c.moveToNext()) {
                return Category.formCursor(c);
            } else {
                return new Category(-1);
            }
        }
    }

    public CategoryTree<Category> getCategoriesTreeWithoutSubTree(long excludingTreeId, boolean includeNoCategory) {
        try (Cursor c = excludingTreeId > 0
            ? getCategoriesWithoutSubtree(excludingTreeId, includeNoCategory) : getCategories(includeNoCategory)) {
            return CategoryTree.createFromCursor(c, Category::formCursor);
        }
    }

    public CategoryTree<Category> getCategoriesTree(boolean includeNoCategory) {
        return getCategoriesTreeWithoutSubTree(-1, includeNoCategory);
    }

    public CategoryTree<Category> getAllCategoriesTree() {
        try (Cursor c = getAllCategories()) {
            return CategoryTree.createFromCursor(c, Category::formCursor);
        }
    }

    public Map<Long, Category> getAllCategoriesMap() {
        return getAllCategoriesTree().asMap();
    }

    public List<Category> getCategoriesList(boolean includeNoCategory) {
        try (Cursor c = getCategories(includeNoCategory)) {
            return categoriesAsList(c);
        }
    }

    public Cursor getAllCategories() {
        return db().query(V_CATEGORY, CategoryViewColumns.NORMAL_PROJECTION, null, null, null, null, null);
    }

    public List<Category> getAllCategoriesList() {
        try (Cursor c = getAllCategories()) {
            return categoriesAsList(c);
        }
    }

    private List<Category> categoriesAsList(Cursor c) {
        List<Category> list = new ArrayList<>();

        while (c.moveToNext()) {
            Category category = Category.formCursor(c);
            list.add(category);
        }
        return list;
    }

    public Cursor getCategories(boolean includeNoCategory) {
        return getCategories(includeNoCategory, null);
    }

    public Cursor filterCategories(CharSequence titleFilter) {
        return getCategories(false, titleFilter);
    }
        
    
    public Cursor getCategories(boolean includeNoCategory, CharSequence titleFilter) {
        String query = CategoryViewColumns._id + (includeNoCategory ? ">=0" : ">0");
        String[] args = null;
        if (titleFilter != null) {
            query += " and (" + CategoryViewColumns.title + " like ? or " + CategoryViewColumns.title + " like ? )";
            args = new String[]{
                    "%" + titleFilter + "%", 
                    "%" + StringUtil.capitalize(titleFilter.toString()) + "%"};
        }
        return db().query(V_CATEGORY, 
                CategoryViewColumns.NORMAL_PROJECTION,
                query,
                args, null, null, null);
    }

    public Cursor getCategoriesWithoutSubtree(long id, boolean includeNoCategory) {
        SQLiteDatabase db = db();
        long left = 0, right = 0;
        try (Cursor c = db.query(CATEGORY_TABLE, new String[]{CategoryColumns.left.name(), CategoryColumns.right.name()},
                CategoryColumns._id + "=?", new String[]{String.valueOf(id)},null,null,null)) {
            if (c.moveToFirst()) {
                left = c.getLong(0);
                right = c.getLong(1);
            }
        }
        return db.query(V_CATEGORY, CategoryViewColumns.NORMAL_PROJECTION,
                "(NOT (" + CategoryViewColumns.left + ">=? AND " + CategoryColumns.right + "<=?)) AND "
                    + CategoryViewColumns._id + (includeNoCategory ? ">=0" : ">0"),
                new String[]{String.valueOf(left), String.valueOf(right)}, null, null, null);
    }

    public List<Category> getCategoriesWithoutSubtreeAsList(long categoryId) {
        List<Category> list = new ArrayList<>();
        try (Cursor c = getCategoriesWithoutSubtree(categoryId, true)) {
            while (c.moveToNext()) {
                Category category = Category.formCursor(c);
                list.add(category);
            }
            return list;
        }
    }

    private static final String INSERT_CATEGORY_UPDATE_RIGHT = "UPDATE " + CATEGORY_TABLE + " SET " + CategoryColumns.right + "=" + CategoryColumns.right + "+2 WHERE " + CategoryColumns.right + ">?";
    private static final String INSERT_CATEGORY_UPDATE_LEFT = "UPDATE " + CATEGORY_TABLE + " SET " + CategoryColumns.left + "=" + CategoryColumns.left + "+2 WHERE " + CategoryColumns.left + ">?";

    public long insertChildCategory(long parentId, Category category) {
        //DECLARE v_leftkey INT UNSIGNED DEFAULT 0;
        //SELECT l INTO v_leftkey FROM `nset` WHERE `id` = ParentID;
        //UPDATE `nset` SET `r` = `r` + 2 WHERE `r` > v_leftkey;
        //UPDATE `nset` SET `l` = `l` + 2 WHERE `l` > v_leftkey;
        //INSERT INTO `nset` (`name`, `l`, `r`) VALUES (NodeName, v_leftkey + 1, v_leftkey + 2);
        int type = getActualCategoryType(parentId, category);
        return insertCategory(CategoryColumns.left.name(), parentId, category.title, type);
    }

    public long insertMateCategory(long categoryId, Category category) {
        //DECLARE v_rightkey INT UNSIGNED DEFAULT 0;
        //SELECT `r` INTO v_rightkey FROM `nset` WHERE `id` = MateID;
        //UPDATE `	nset` SET `r` = `r` + 2 WHERE `r` > v_rightkey;
        //UPDATE `nset` SET `l` = `l` + 2 WHERE `l` > v_rightkey;
        //INSERT `nset` (`name`, `l`, `r`) VALUES (NodeName, v_rightkey + 1, v_rightkey + 2);
        Category mate = getCategoryWithParent(categoryId);
        long parentId = mate.getParentId();
        int type = getActualCategoryType(parentId, category);
        return insertCategory(CategoryColumns.right.name(), categoryId, category.title, type);
    }

    private int getActualCategoryType(long parentId, Category category) {
        int type = category.type;
        if (parentId > 0) {
            Category parent = getCategoryWithParent(parentId);
            type = parent.type;
        }
        return type;
    }

    private long insertCategory(String field, long categoryId, String title, int type) {
        int num = 0;
        SQLiteDatabase db = db();
        Cursor c = db.query(CATEGORY_TABLE, new String[]{field},
                CategoryColumns._id + "=?", new String[]{String.valueOf(categoryId)}, null, null, null);
        try {
            if (c.moveToFirst()) {
                num = c.getInt(0);
            }
        } finally {
            c.close();
        }
        String[] args = new String[]{String.valueOf(num)};
        db.execSQL(INSERT_CATEGORY_UPDATE_RIGHT, args);
        db.execSQL(INSERT_CATEGORY_UPDATE_LEFT, args);
        ContentValues values = new ContentValues();
        values.put(CategoryColumns.title.name(), title);
        int left = num + 1;
        int right = num + 2;
        values.put(CategoryColumns.left.name(), left);
        values.put(CategoryColumns.right.name(), right);
        values.put(CategoryColumns.type.name(), type);
        long id = db.insert(CATEGORY_TABLE, null, values);
        updateChildCategoriesType(type, left, right);
        return id;
    }

    private static final String CATEGORY_UPDATE_CHILDREN_TYPES = "UPDATE " + CATEGORY_TABLE + " SET " + CategoryColumns.type + "=? WHERE " + CategoryColumns.left + ">? AND " + CategoryColumns.right + "<?";

    private void updateChildCategoriesType(int type, int left, int right) {
        db().execSQL(CATEGORY_UPDATE_CHILDREN_TYPES, new Object[]{type, left, right});
    }

    private static final String DELETE_CATEGORY_UPDATE1 = "UPDATE " + TRANSACTION_TABLE
            + " SET " + TransactionColumns.category_id + "=0 WHERE "
            + TransactionColumns.category_id + " IN ("
            + "SELECT " + CategoryColumns._id + " FROM " + CATEGORY_TABLE + " WHERE "
            + CategoryColumns.left + " BETWEEN ? AND ?)";
    private static final String DELETE_CATEGORY_UPDATE2 = "UPDATE " + CATEGORY_TABLE
            + " SET " + CategoryColumns.left + "=(CASE WHEN " + CategoryColumns.left + ">%s THEN "
            + CategoryColumns.left + "-%s ELSE " + CategoryColumns.left + " END),"
            + CategoryColumns.right + "=" + CategoryColumns.right + "-%s"
            + " WHERE " + CategoryColumns.right + ">%s";

    public void deleteCategory(long categoryId) {
        //DECLARE v_leftkey, v_rightkey, v_width INT DEFAULT 0;
        //
        //SELECT
        //	`l`, `r`, `r` - `l` + 1 INTO v_leftkey, v_rightkey, v_width
        //FROM `nset`
        //WHERE
        //	`id` = NodeID;
        //
        //DELETE FROM `nset` WHERE `l` BETWEEN v_leftkey AND v_rightkey;
        //
        //UPDATE `nset`
        //SET
        //	`l` = IF(`l` > v_leftkey, `l` - v_width, `l`),
        //	`r` = `r` - v_width
        //WHERE
        //	`r` > v_rightkey;
        SQLiteDatabase db = db();
        int left = 0, right = 0;
        Cursor c = db.query(CATEGORY_TABLE, new String[]{CategoryColumns.left.name(), CategoryColumns.right.name()},
                CategoryColumns._id + "=?", new String[]{String.valueOf(categoryId)}, null, null, null);
        try {
            if (c.moveToFirst()) {
                left = c.getInt(0);
                right = c.getInt(1);
            }
        } finally {
            c.close();
        }
        db.beginTransaction();
        try {
            int width = right - left + 1;
            String[] args = new String[]{String.valueOf(left), String.valueOf(right)};
            db.execSQL(DELETE_CATEGORY_UPDATE1, args);
            db.delete(CATEGORY_TABLE, CategoryColumns.left + " BETWEEN ? AND ?", args);
            db.execSQL(String.format(DELETE_CATEGORY_UPDATE2, left, width, width, right));
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private void updateCategory(long id, String title, int type) {
        ContentValues values = new ContentValues();
        values.put(CategoryColumns.title.name(), title);
        values.put(CategoryColumns.type.name(), type);
        db().update(CATEGORY_TABLE, values, CategoryColumns._id + "=?", new String[]{String.valueOf(id)});
    }

    public void insertCategoryTreeInTransaction(CategoryTree<Category> tree) {
        db().delete("category", "_id > 0", null);
        insertCategoryInTransaction(tree);
        updateCategoryTreeInTransaction(tree);
    }

    private void insertCategoryInTransaction(CategoryTree<Category> tree) {
        for (Category category : tree) {
            reInsertEntity(category);
            if (category.hasChildren()) {
                insertCategoryInTransaction(category.children);
            }
        }
    }

    public void updateCategoryTree(CategoryTree<Category> tree) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            updateCategoryTreeInTransaction(tree);
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private static final String WHERE_CATEGORY_ID = CategoryColumns._id + "=?";

    private void updateCategoryTreeInTransaction(CategoryTree<Category> tree) {
        int left = 1;
        int right = 2;
        ContentValues values = new ContentValues();
        String[] sid = new String[1];
        for (Category c : tree) {
            values.put(CategoryColumns.left.name(), c.left);
            values.put(CategoryColumns.right.name(), c.right);
            sid[0] = String.valueOf(c.id);
            db().update(CATEGORY_TABLE, values, WHERE_CATEGORY_ID, sid);
            if (c.hasChildren()) {
                updateCategoryTreeInTransaction(c.children);
            }
            if (c.left < left) {
                left = c.left;
            }
            if (c.right > right) {
                right = c.right;
            }
        }
        values.put(CategoryColumns.left.name(), left - 1);
        values.put(CategoryColumns.right.name(), right + 1);
        sid[0] = String.valueOf(Category.NO_CATEGORY_ID);
        db().update(CATEGORY_TABLE, values, WHERE_CATEGORY_ID, sid);
    }

    // ===================================================================
    // SMS TEMPLATES >>
    // ===================================================================

    public List<SmsTemplate> getSmsTemplatesForCategory(long categoryId) {
        try (Cursor c = db().query(SMS_TEMPLATES_TABLE, NORMAL_PROJECTION, category_id + "=?",
                new String[]{String.valueOf(categoryId)}, null, null, title.name())) {
            List<SmsTemplate> res = new ArrayList<>(c.getCount());
            while (c.moveToNext()) {
                SmsTemplate a = SmsTemplate.fromCursor(c);
                res.add(a);
            }
            return res;
        }
    }

    public List<SmsTemplate> getSmsTemplatesByNumber(String smsNumber) {
        try (Cursor c = db().rawQuery(
                String.format("select %s from %s where %s=? order by %s, length(%s) desc",
                    DatabaseUtils.generateSelectClause(NORMAL_PROJECTION, null),
                    SMS_TEMPLATES_TABLE, title, sort_order, template), new String[]{smsNumber})) {
            List<SmsTemplate> res = new ArrayList<>(c.getCount());
            while (c.moveToNext()) {
                SmsTemplate a = SmsTemplate.fromCursor(c);
                res.add(a);
            }
            return res;
        }
    }

    public Set<String> findAllSmsTemplateNumbers() {
        try (Cursor c = db().rawQuery("select distinct " + title + " from " + SMS_TEMPLATES_TABLE +
                " where " + SmsTemplateColumns.template + " is not null", null)) {
            Set<String> res = new HashSet<>(c.getCount());
            while (c.moveToNext()) {
                res.add(c.getString(0));
            }
            return res;
        }
    }

    public Cursor getAllSmsTemplates() {
        return db().query(SMS_TEMPLATES_TABLE, NORMAL_PROJECTION,
                SmsTemplateColumns.template + " is not null", null, null, null, title.name());
    }

    public Cursor getSmsTemplatesWithFullInfo() {
        return getSmsTemplatesWithFullInfo(null);
    }
    
    public Cursor getSmsTemplatesWithFullInfo(final String filter) {
        String nativeQuery = String.format(
                "select %s, c.%s as %s, c.%s as %s " +
                "from %s t left outer join %s c on t.%s = c.%s ",
                DatabaseUtils.generateSelectClause(NORMAL_PROJECTION, "t"),
                CategoryViewColumns.title, SmsTemplateListColumns.cat_name, CategoryViewColumns.level, SmsTemplateListColumns.cat_level,
                SMS_TEMPLATES_TABLE,
                V_CATEGORY,
                category_id, CategoryViewColumns._id);
        if (!StringUtil.isEmpty(filter)) {
            nativeQuery += String.format("where t.%s like '%%%s%%' or t.%s like '%%%2$s%%' ",
                    CategoryViewColumns.title, filter, SmsTemplateColumns.template);
        }
        nativeQuery += "order by t." + sort_order;
        
        return db().rawQuery(nativeQuery, new String[]{});
    }

    public long duplicateSmsTemplateBelowOriginal(long id) {
        long newId = duplicate(SmsTemplate.class, id);
        long nextOrderItem = getNextByOrder(SmsTemplate.class, id);
        if (nextOrderItem > 0) {
            moveItemByChangingOrder(SmsTemplate.class, newId, nextOrderItem);
        }
        return newId;
    }

    // ===================================================================
    // ATTRIBUTES
    // ===================================================================

    public ArrayList<Attribute> getAttributesForCategory(long categoryId) {
        try (Cursor c = db().query(V_ATTRIBUTES, AttributeColumns.NORMAL_PROJECTION,
                CategoryAttributeColumns.CATEGORY_ID + "=?", new String[]{String.valueOf(categoryId)},
                null, null, AttributeColumns.TITLE)) {
            ArrayList<Attribute> list = new ArrayList<Attribute>(c.getCount());
            while (c.moveToNext()) {
                Attribute a = Attribute.fromCursor(c);
                list.add(a);
            }
            return list;
        }
    }

    public ArrayList<Attribute> getAllAttributesForCategory(long categoryId) {
        Category category = getCategoryWithParent(categoryId);
        try (Cursor c = db().query(V_ATTRIBUTES, AttributeColumns.NORMAL_PROJECTION,
                AttributeViewColumns.CATEGORY_LEFT + "<= ? AND " + AttributeViewColumns.CATEGORY_RIGHT + " >= ?",
                new String[]{String.valueOf(category.left), String.valueOf(category.right)},
                null, null, AttributeColumns.TITLE)) {
            ArrayList<Attribute> list = new ArrayList<>(c.getCount());
            while (c.moveToNext()) {
                Attribute a = Attribute.fromCursor(c);
                list.add(a);
            }
            return list;
        }
    }

    public Attribute getSystemAttribute(SystemAttribute a) {
        Attribute sa = getAttribute(a.id);
        sa.title = context.getString(a.titleId);
        return sa;
    }

    public Attribute getAttribute(long id) {
        try (Cursor c = db().query(ATTRIBUTES_TABLE, AttributeColumns.NORMAL_PROJECTION,
                AttributeColumns.ID + "=?", new String[]{String.valueOf(id)},
                null, null, null)) {
            if (c.moveToFirst()) {
                return Attribute.fromCursor(c);
            }
        }
        return new Attribute();
    }

    public long insertOrUpdate(Attribute attribute) {
        if (attribute.id == -1) {
            return insertAttribute(attribute);
        } else {
            updateAttribute(attribute);
            return attribute.id;
        }
    }

    public void deleteAttribute(long id) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            Attribute attr = getAttribute(id);
            String[] p = new String[]{String.valueOf(id)};
            db.delete(ATTRIBUTES_TABLE, AttributeColumns.ID + "=?", p);
            db.delete(CATEGORY_ATTRIBUTE_TABLE, CategoryAttributeColumns.ATTRIBUTE_ID + "=?", p);
            db.delete(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.ATTRIBUTE_ID + "=?", p);
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private long insertAttribute(Attribute attribute) {
        return db().insert(ATTRIBUTES_TABLE, null, attribute.toValues());
    }

    private void updateAttribute(Attribute attribute) {
        db().update(ATTRIBUTES_TABLE, attribute.toValues(), AttributeColumns.ID + "=?", new String[]{String.valueOf(attribute.id)});
    }

    public Cursor getAllAttributes() {
        return db().query(ATTRIBUTES_TABLE, AttributeColumns.NORMAL_PROJECTION,
                AttributeColumns.ID + ">0", null, null, null, AttributeColumns.TITLE);
    }

    public Map<Long, String> getAllAttributesMap() {
        try (Cursor c = db().query(V_ATTRIBUTES, AttributeViewColumns.NORMAL_PROJECTION, null, null, null, null,
                AttributeViewColumns.CATEGORY_ID + ", " + AttributeViewColumns.TITLE)) {
            HashMap<Long, String> attributes = new HashMap<Long, String>();
            StringBuilder sb = null;
            long prevCategoryId = -1;
            while (c.moveToNext()) {
                long categoryId = c.getLong(AttributeViewColumns.Indicies.CATEGORY_ID);
                String name = c.getString(AttributeViewColumns.Indicies.NAME);
                if (prevCategoryId != categoryId) {
                    if (sb != null) {
                        attributes.put(prevCategoryId, sb.append("]").toString());
                        sb.setLength(1);
                    } else {
                        sb = new StringBuilder();
                        sb.append("[");
                    }
                    prevCategoryId = categoryId;
                }
                if (sb.length() > 1) {
                    sb.append(", ");
                }
                sb.append(name);
            }
            if (sb != null) {
                attributes.put(prevCategoryId, sb.append("]").toString());
            }
            return attributes;
        }
    }

    public Map<Long, String> getAllAttributesForTransaction(long transactionId) {
        Cursor c = db().query(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.NORMAL_PROJECTION,
                TransactionAttributeColumns.TRANSACTION_ID + "=? AND " + TransactionAttributeColumns.ATTRIBUTE_ID + ">=0",
                new String[]{String.valueOf(transactionId)},
                null, null, null);
        try {
            HashMap<Long, String> attributes = new HashMap<Long, String>();
            while (c.moveToNext()) {
                long attributeId = c.getLong(TransactionAttributeColumns.Indicies.ATTRIBUTE_ID);
                String value = c.getString(TransactionAttributeColumns.Indicies.VALUE);
                attributes.put(attributeId, value);
            }
            return attributes;
        } finally {
            c.close();
        }
    }

    public EnumMap<SystemAttribute, String> getSystemAttributesForTransaction(long transactionId) {
        Cursor c = db().query(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.NORMAL_PROJECTION,
                TransactionAttributeColumns.TRANSACTION_ID + "=? AND " + TransactionAttributeColumns.ATTRIBUTE_ID + "<0",
                new String[]{String.valueOf(transactionId)},
                null, null, null);
        try {
            EnumMap<SystemAttribute, String> attributes = new EnumMap<SystemAttribute, String>(SystemAttribute.class);
            while (c.moveToNext()) {
                long attributeId = c.getLong(TransactionAttributeColumns.Indicies.ATTRIBUTE_ID);
                String value = c.getString(TransactionAttributeColumns.Indicies.VALUE);
                attributes.put(SystemAttribute.forId(attributeId), value);
            }
            return attributes;
        } finally {
            c.close();
        }
    }


    /**
     * Sets status=CL (Cleared) for the selected transactions
     *
     * @param ids selected transactions' ids
     */
    public void clearSelectedTransactions(long[] ids) {
        String sql = "UPDATE " + TRANSACTION_TABLE + " SET " + TransactionColumns.status + "='" + TransactionStatus.CL + "'";
        runInTransaction(sql, ids);
    }

    /**
     * Sets status=RC (Reconciled) for the selected transactions
     *
     * @param ids selected transactions' ids
     */
    public void reconcileSelectedTransactions(long[] ids) {
        String sql = "UPDATE " + TRANSACTION_TABLE + " SET " + TransactionColumns.status + "='" + TransactionStatus.RC + "'";
        runInTransaction(sql, ids);
    }

    /**
     * Deletes the selected transactions
     *
     * @param ids selected transactions' ids
     */
    public void deleteSelectedTransactions(long[] ids) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            for (long id : ids) {
                deleteTransactionNoDbTransaction(id);
            }
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private void runInTransaction(String sql, long[] ids) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            int count = ids.length;
            int bucket = 100;
            int num = 1 + count / bucket;
            for (int i = 0; i < num; i++) {
                int x = bucket * i;
                int y = Math.min(count, bucket * (i + 1));
                String script = createSql(sql, ids, x, y);
                db.execSQL(script);
            }
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private String createSql(String updateSql, long[] ids, int x, int y) {
        StringBuilder sb = new StringBuilder(updateSql)
                .append(" WHERE ")
                .append(TransactionColumns.is_template)
                .append("=0 AND ")
                .append(TransactionColumns.parent_id)
                .append("=0 AND ")
                .append(TransactionColumns._id)
                .append(" IN (");
        for (int i = x; i < y; i++) {
            if (i > x) {
                sb.append(",");
            }
            sb.append(ids[i]);
        }
        sb.append(")");
        return sb.toString();
    }

    private static final String UPDATE_LAST_RECURRENCE =
            "UPDATE " + TRANSACTION_TABLE + " SET " + TransactionColumns.last_recurrence + "=? WHERE " + TransactionColumns._id + "=?";

    public long[] storeMissedSchedules(List<RestoredTransaction> restored, long now) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            int count = restored.size();
            long[] restoredIds = new long[count];
            HashMap<Long, Transaction> transactions = new HashMap<Long, Transaction>();
            for (int i = 0; i < count; i++) {
                RestoredTransaction rt = restored.get(i);
                long transactionId = rt.transactionId;
                Transaction t = transactions.get(transactionId);
                if (t == null) {
                    t = getTransaction(transactionId);
                    transactions.put(transactionId, t);
                }
                t.id = -1;
                t.dateTime = rt.dateTime.getTime();
                t.status = TransactionStatus.RS;
                t.isTemplate = 0;
                restoredIds[i] = insertOrUpdate(t);
                t.id = transactionId;
            }
            for (Transaction t : transactions.values()) {
                db.execSQL(UPDATE_LAST_RECURRENCE, new Object[]{now, t.id});
            }
            db.setTransactionSuccessful();
            return restoredIds;
        } finally {
            db.endTransaction();
        }
    }

    /**
     * @param accountId
     * @param period
     * @return
     */
    public int getCustomClosingDay(long accountId, int period) {
        String where = CreditCardClosingDateColumns.ACCOUNT_ID + "=? AND " +
                CreditCardClosingDateColumns.PERIOD + "=?";

        Cursor c = db().query(CCARD_CLOSING_DATE_TABLE, new String[]{CreditCardClosingDateColumns.CLOSING_DAY},
                where, new String[]{Long.toString(accountId), Integer.toString(period)}, null, null, null);

        int res = 0;
        try {
            if (c != null) {
                if (c.getCount() > 0) {
                    c.moveToFirst();
                    res = c.getInt(0);
                } else {
                    res = 0;
                }
            } else {
                // there is no custom closing day in database for the given account id an period
                res = 0;
            }
        } catch (SQLiteException e) {
            res = 0;
        } finally {
            c.close();
        }
        return res;
    }


    public void setCustomClosingDay(long accountId, int period, int closingDay) {
        ContentValues values = new ContentValues();
        values.put(CreditCardClosingDateColumns.ACCOUNT_ID, Long.toString(accountId));
        values.put(CreditCardClosingDateColumns.PERIOD, Integer.toString(period));
        values.put(CreditCardClosingDateColumns.CLOSING_DAY, Integer.toString(closingDay));
        db().insert(CCARD_CLOSING_DATE_TABLE, null, values);
    }

    public void deleteCustomClosingDay(long accountId, int period) {
        String where = CreditCardClosingDateColumns.ACCOUNT_ID + "=? AND " +
                CreditCardClosingDateColumns.PERIOD + "=?";
        String[] args = new String[]{Long.toString(accountId), Integer.toString(period)};
        db().delete(CCARD_CLOSING_DATE_TABLE, where, args);
    }

    public void updateCustomClosingDay(long accountId, int period, int closingDay) {
        // delete previous content
        deleteCustomClosingDay(accountId, period);

        // save new value
        setCustomClosingDay(accountId, period, closingDay);
    }

    /**
     * Re-populates running_balance table for all accounts
     */
    public void rebuildRunningBalances() {
        List<Account> accounts = getAllAccountsList();
        for (Account account : accounts) {
            rebuildRunningBalanceForAccount(account);
        }
    }

    /**
     * Re-populates running_balance for specific account
     *
     * @param account selected account
     */
    public void rebuildRunningBalanceForAccount(Account account) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            String accountId = String.valueOf(account.getId());
            db.execSQL("delete from running_balance where account_id=?", new Object[]{accountId});
            WhereFilter filter = new WhereFilter("");
            filter.put(Criteria.eq(BlotterFilter.FROM_ACCOUNT_ID, accountId));
            filter.asc("datetime");
            filter.asc("_id");
            Object[] values = new Object[4];
            values[0] = accountId;
            try (Cursor c = getBlotterForAccountWithSplits(filter)) {
                long balance = 0;
                while (c.moveToNext()) {
                    long parentId = c.getLong(BlotterColumns.parent_id.ordinal());
                    int isTransfer = c.getInt(BlotterColumns.is_transfer.ordinal());
                    if (parentId > 0) {
                        if (isTransfer >= 0) {
                            // we only interested in the second part of the transfer-split
                            // which is marked with is_transfer=-1 (see v_blotter_for_account_with_splits)
                            continue;
                        }
                    }
                    long fromAccountId = c.getLong(BlotterColumns.from_account_id.ordinal());
                    long toAccountId = c.getLong(BlotterColumns.to_account_id.ordinal());
                    if (toAccountId > 0 && toAccountId == fromAccountId) {
                        // weird bug when a transfer is done from an account to the same account
                        continue;
                    }
                    balance += c.getLong(BlotterColumns.from_amount.ordinal());
                    values[1] = c.getString(BlotterColumns._id.ordinal());
                    values[2] = c.getString(BlotterColumns.datetime.ordinal());
                    values[3] = balance;
                    db.execSQL("insert into running_balance(account_id,transaction_id,datetime,balance) values (?,?,?,?)", values);
                }
            }
            updateAccountLastTransactionDate(account.id);
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private static final String[] SUM_FROM_AMOUNT = new String[]{"sum(from_amount)"};

    public long fetchBudgetBalance(Map<Long, Category> categories, Map<Long, Project> projects, Budget b) {
        String where = Budget.createWhere(b, categories, projects);
        Cursor c = db().query(V_BLOTTER_FOR_ACCOUNT_WITH_SPLITS, SUM_FROM_AMOUNT, where, null, null, null, null);
        try {
            if (c.moveToNext()) {
                return c.getLong(0);
            }
        } finally {
            c.close();
        }
        return 0;
    }

    public void recalculateAccountsBalances() {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            for (Account account : getAllAccountsList()) {
                recalculateAccountBalances(account.id);
            }
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private void recalculateAccountBalances(long accountId) {
        TransactionsTotalCalculator calculator = new TransactionsTotalCalculator(this,
                enhanceFilterForAccountBlotter(WhereFilter.empty()
                        .eq(BlotterFilter.FROM_ACCOUNT_ID, String.valueOf(accountId))));
        Total total = calculator.getAccountTotal();
        ContentValues values = new ContentValues();
        values.put(AccountColumns.TOTAL_AMOUNT, total.balance);
        db().update(ACCOUNT_TABLE, values, AccountColumns.ID + "=?", new String[]{String.valueOf(accountId)});
        Log.i("DatabaseImport", "Recalculating amount for " + accountId);
    }

    private long fetchAccountBalance(long accountId) {
        try (Cursor c = db().query(V_BLOTTER_FOR_ACCOUNT_WITH_SPLITS, new String[]{"SUM(" + BlotterColumns.from_amount + ")"},
                BlotterColumns.from_account_id + "=? and (" + BlotterColumns.parent_id + "=0 or " + BlotterColumns.is_transfer + "=-1)",
                new String[]{String.valueOf(accountId)}, null, null, null)) {
            if (c.moveToFirst()) {
                return c.getLong(0);
            }
            return 0;
        }
    }

    public void saveRate(ExchangeRate r) {
        replaceRate(r, r.date);
    }

    public void replaceRate(ExchangeRate rate, long originalDate) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            replaceRateInTransaction(rate, originalDate, db);
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private void replaceRateInTransaction(ExchangeRate rate, long originalDate, SQLiteDatabase db) {
        deleteRateInTransaction(rate.fromCurrencyId, rate.toCurrencyId, originalDate, db);
        saveBothRatesInTransaction(rate, db);
    }

    private void saveBothRatesInTransaction(ExchangeRate r, SQLiteDatabase db) {
        r.date = DateUtils.atMidnight(r.date);
        saveRateInTransaction(db, r);
        saveRateInTransaction(db, r.flip());
    }

    private void saveRateInTransaction(SQLiteDatabase db, ExchangeRate r) {
        db.insert(EXCHANGE_RATES_TABLE, null, r.toValues());
    }

    public void saveDownloadedRates(List<ExchangeRate> downloadedRates) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            for (ExchangeRate r : downloadedRates) {
                if (r.isOk()) {
                    replaceRateInTransaction(r, r.date, db);
                }
            }
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    public ExchangeRate findRate(Currency fromCurrency, Currency toCurrency, long date) {
        long day = DateUtils.atMidnight(date);
        Cursor c = db().query(EXCHANGE_RATES_TABLE, ExchangeRateColumns.NORMAL_PROJECTION, ExchangeRateColumns.NORMAL_PROJECTION_WHERE,
                new String[]{String.valueOf(fromCurrency.id), String.valueOf(toCurrency.id), String.valueOf(day)}, null, null, null);
        try {
            if (c.moveToFirst()) {
                return ExchangeRate.fromCursor(c);
            }
        } finally {
            c.close();
        }
        return null;
    }

    public List<ExchangeRate> findRates(Currency fromCurrency) {
        List<ExchangeRate> rates = new ArrayList<ExchangeRate>();
        Cursor c = db().query(EXCHANGE_RATES_TABLE, ExchangeRateColumns.NORMAL_PROJECTION, ExchangeRateColumns.from_currency_id + "=?",
                new String[]{String.valueOf(fromCurrency.id)}, null, null, ExchangeRateColumns.rate_date + " desc");
        try {
            while (c.moveToNext()) {
                rates.add(ExchangeRate.fromCursor(c));
            }
        } finally {
            c.close();
        }
        return rates;
    }

    public List<ExchangeRate> findRates(Currency fromCurrency, Currency toCurrency) {
        List<ExchangeRate> rates = new ArrayList<ExchangeRate>();
        Cursor c = db().query(EXCHANGE_RATES_TABLE, ExchangeRateColumns.NORMAL_PROJECTION,
                ExchangeRateColumns.from_currency_id + "=? and " + ExchangeRateColumns.to_currency_id + "=?",
                new String[]{String.valueOf(fromCurrency.id), String.valueOf(toCurrency.id)},
                null, null, ExchangeRateColumns.rate_date + " desc");
        try {
            while (c.moveToNext()) {
                rates.add(ExchangeRate.fromCursor(c));
            }
        } finally {
            c.close();
        }
        return rates;
    }

    public ExchangeRateProvider getLatestRates() {
        LatestExchangeRates m = new LatestExchangeRates();
        Cursor c = db().query(EXCHANGE_RATES_TABLE, ExchangeRateColumns.LATEST_RATE_PROJECTION, null, null, ExchangeRateColumns.LATEST_RATE_GROUP_BY, null, null);
        fillRatesCollection(m, c);
        return m;
    }

    public ExchangeRateProvider getHistoryRates() {
        HistoryExchangeRates m = new HistoryExchangeRates();
        Cursor c = db().query(EXCHANGE_RATES_TABLE, ExchangeRateColumns.NORMAL_PROJECTION, null, null, null, null, null);
        fillRatesCollection(m, c);
        return m;
    }

    private void fillRatesCollection(ExchangeRatesCollection m, Cursor c) {
        try {
            while (c.moveToNext()) {
                ExchangeRate r = ExchangeRate.fromCursor(c);
                m.addRate(r);
            }
        } finally {
            c.close();
        }
    }

    public void deleteRate(ExchangeRate rate) {
        deleteRate(rate.fromCurrencyId, rate.toCurrencyId, rate.date);
    }

    public void deleteRate(long fromCurrencyId, long toCurrencyId, long date) {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            deleteRateInTransaction(fromCurrencyId, toCurrencyId, date, db);
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }
    }

    private void deleteRateInTransaction(long fromCurrencyId, long toCurrencyId, long date, SQLiteDatabase db) {
        long d = DateUtils.atMidnight(date);
        db.delete(EXCHANGE_RATES_TABLE, ExchangeRateColumns.DELETE_CLAUSE,
                new String[]{String.valueOf(fromCurrencyId), String.valueOf(toCurrencyId), String.valueOf(d)});
        db.delete(EXCHANGE_RATES_TABLE, ExchangeRateColumns.DELETE_CLAUSE,
                new String[]{String.valueOf(toCurrencyId), String.valueOf(fromCurrencyId), String.valueOf(d)});
    }

    public Total getAccountsTotalInHomeCurrency() {
        Currency homeCurrency = getHomeCurrency();
        return getAccountsTotal(homeCurrency);
    }

    /**
     * Calculates total in every currency for all accounts
     */
    public Total[] getAccountsTotal() {
        List<Account> accounts = getAllAccountsList();
        Map<Currency, Total> totalsMap = new HashMap<Currency, Total>();
        for (Account account : accounts) {
            if (account.shouldIncludeIntoTotals()) {
                Currency currency = account.currency;
                Total total = totalsMap.get(currency);
                if (total == null) {
                    total = new Total(currency);
                    totalsMap.put(currency, total);
                }
                total.balance += account.totalAmount;
            }
        }
        Collection<Total> values = totalsMap.values();
        return values.toArray(new Total[values.size()]);
    }

    /**
     * Calculates total in home currency for all accounts
     */
    public Total getAccountsTotal(Currency homeCurrency) {
        ExchangeRateProvider rates = getLatestRates();
        List<Account> accounts = getAllAccountsList();
        BigDecimal total = BigDecimal.ZERO;
        for (Account account : accounts) {
            if (account.shouldIncludeIntoTotals()) {
                if (account.currency.id == homeCurrency.id) {
                    total = total.add(BigDecimal.valueOf(account.totalAmount));
                } else {
                    ExchangeRate rate = rates.getRate(account.currency, homeCurrency);
                    if (rate == ExchangeRate.NA) {
                        return new Total(homeCurrency, TotalError.lastRateError(account.currency));
                    } else {
                        total = total.add(BigDecimal.valueOf(rate.rate * account.totalAmount));
                    }
                }
            }
        }
        Total result = new Total(homeCurrency);
        result.balance = total.longValue();
        return result;
    }

    public List<Long> findAccountsByNumber(String numberEnding) {
        try (Cursor c = db().rawQuery(
                "select " + AccountColumns.ID + " from " + ACCOUNT_TABLE +
                        " where " + AccountColumns.NUMBER + " like ?",
                new String[]{"%" + numberEnding})) {

            List<Long> res = new ArrayList<>(c.getCount());
            while (c.moveToNext()) {
                res.add(c.getLong(0));
            }
            return res;
        }
    }

    public boolean singleCurrencyOnly() {
        long currencyId = getSingleCurrencyId();
        return currencyId > 0;
    }

    private long getSingleCurrencyId() {
        try (Cursor c = db().rawQuery("select distinct " + AccountColumns.CURRENCY_ID + " from " + ACCOUNT_TABLE +
                " where " + AccountColumns.IS_INCLUDE_INTO_TOTALS + "=1 and " + AccountColumns.IS_ACTIVE + "=1", null)) {

            if (c.getCount() == 1) {
                c.moveToFirst();
                return c.getLong(0);
            }
            return -1;
        }
    }

    public void setDefaultHomeCurrency() {
        Currency homeCurrency = getHomeCurrency();
        long singleCurrencyId = getSingleCurrencyId();
        if (homeCurrency == Currency.EMPTY && singleCurrencyId > 0) {
            Currency c = get(Currency.class, singleCurrencyId);
            c.isDefault = true;
            saveOrUpdate(c);
        }
    }

    public void purgeAccountAtDate(Account account, long date) {
        long nearestTransactionId = findNearestOlderTransactionId(account, date);
        if (nearestTransactionId > 0) {
            SQLiteDatabase db = db();
            db.beginTransaction();
            try {
                Transaction newTransaction = createTransactionFromNearest(account, nearestTransactionId);
                breakSplitTransactions(account, date);
                deleteOldTransactions(account, date);
                insertWithoutUpdatingBalance(newTransaction);
                db.execSQL(INSERT_RUNNING_BALANCE, new Object[]{account.id, newTransaction.id, newTransaction.dateTime, newTransaction.fromAmount});
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }
        }
    }

    private Transaction createTransactionFromNearest(Account account, long nearestTransactionId) {
        Transaction nearestTransaction = get(Transaction.class, nearestTransactionId);
        long balance = getAccountBalanceForTransaction(account, nearestTransaction);
        Transaction newTransaction = new Transaction();
        newTransaction.fromAccountId = account.id;
        newTransaction.dateTime = DateUtils.atDayEnd(nearestTransaction.dateTime);
        newTransaction.fromAmount = balance;
        Payee payee = findOrInsertEntityByTitle(Payee.class, context.getString(R.string.purge_account_payee));
        newTransaction.payeeId = payee != null ? payee.id : 0;
        newTransaction.status = TransactionStatus.CL;
        return newTransaction;
    }

    private static final String BREAK_SPLIT_TRANSACTIONS_1 = UPDATE_ORPHAN_TRANSACTIONS_1 + " " +
            "AND " + TransactionColumns.datetime + "<=?";
    private static final String BREAK_SPLIT_TRANSACTIONS_2 = UPDATE_ORPHAN_TRANSACTIONS_2 + " " +
            "AND " + TransactionColumns.datetime + "<=?";

    private void breakSplitTransactions(Account account, long date) {
        SQLiteDatabase db = db();
        long dayEnd = DateUtils.atDayEnd(date);
        db.execSQL(BREAK_SPLIT_TRANSACTIONS_1, new Object[]{account.id, dayEnd});
        db.execSQL(BREAK_SPLIT_TRANSACTIONS_2, new Object[]{account.id, dayEnd});
        db.delete(TRANSACTION_ATTRIBUTE_TABLE, TransactionAttributeColumns.TRANSACTION_ID
                        + " in (SELECT _id from " + TRANSACTION_TABLE + " where " + TransactionColumns.datetime + "<=?)",
                new String[]{String.valueOf(dayEnd)});
    }

    public void deleteOldTransactions(Account account, long date) {
        SQLiteDatabase db = db();
        long dayEnd = DateUtils.atDayEnd(date);
        db.delete("transactions", "from_account_id=? and datetime<=? and is_template=0",
                new String[]{String.valueOf(account.id), String.valueOf(dayEnd)});
        db.delete("running_balance", "account_id=? and datetime<=?",
                new String[]{String.valueOf(account.id), String.valueOf(dayEnd)});
    }

    public long getAccountBalanceForTransaction(Account a, Transaction t) {
        return DatabaseUtils.rawFetchLongValue(this, "select balance from running_balance where account_id=? and transaction_id=?",
                new String[]{String.valueOf(a.id), String.valueOf(t.id)});
    }

    public long findNearestOlderTransactionId(Account account, long date) {
        return DatabaseUtils.rawFetchId(this,
                "select _id from v_blotter where from_account_id=? and datetime<=? order by datetime desc limit 1",
                new String[]{String.valueOf(account.id), String.valueOf(DateUtils.atDayEnd(date))});
    }

    public long findLatestTransactionDate(long accountId) {
        return DatabaseUtils.rawFetchLongValue(this,
                "select datetime from running_balance where account_id=? order by datetime desc limit 1",
                new String[]{String.valueOf(accountId)});
    }

    private static final String ACCOUNT_LAST_TRANSACTION_DATE_UPDATE = "UPDATE " + ACCOUNT_TABLE
            + " SET " + AccountColumns.LAST_TRANSACTION_DATE + "=? WHERE " + AccountColumns.ID + "=?";

    private void updateAccountLastTransactionDate(long accountId) {
        if (accountId <= 0) {
            return;
        }
        long lastTransactionDate = findLatestTransactionDate(accountId);
        db().execSQL(ACCOUNT_LAST_TRANSACTION_DATE_UPDATE, new Object[]{lastTransactionDate, accountId});
    }

    public void updateAccountsLastTransactionDate() {
        List<Account> accounts = getAllAccountsList();
        for (Account account : accounts) {
            updateAccountLastTransactionDate(account.id);
        }
    }

    public void restoreSystemEntities() {
        SQLiteDatabase db = db();
        db.beginTransaction();
        try {
            restoreCategories();
            restoreAttributes();
            restoreProjects();
            restoreLocations();
            db.setTransactionSuccessful();
        } catch (Exception e) {
            Log.e("Financisto", "Unable to restore system entities", e);
        } finally {
            db.endTransaction();
        }
    }

    private void restoreCategories() {
        reInsertEntity(Category.noCategory());
        reInsertEntity(Category.splitCategory());
        CategoryTree<Category> tree = getCategoriesTree(false);
        tree.reIndex();
        updateCategoryTree(tree);
    }

    private void restoreAttributes() {
        reInsertEntity(Attribute.deleteAfterExpired());
    }

    private void restoreProjects() {
        reInsertEntity(Project.noProject());
    }

    private void restoreLocations() {
        reInsertEntity(MyLocation.currentLocation());
    }

    public long getLastRunningBalanceForAccount(Account account) {
        return DatabaseUtils.rawFetchLongValue(this, "select balance from running_balance where account_id=? order by datetime desc, transaction_id desc limit 1",
                new String[]{String.valueOf(account.id)});
    }
}