package org.commcare.models.database;

import android.content.ContentValues;
import android.database.Cursor;

import net.sqlcipher.database.SQLiteDatabase;

import org.commcare.CommCareApplication;
import org.commcare.interfaces.AppFilePathBuilder;
import org.commcare.models.encryption.EncryptionIO;
import org.commcare.modern.database.DatabaseHelper;
import org.commcare.modern.database.TableBuilder;
import org.commcare.modern.util.Pair;
import org.commcare.utils.FileUtil;
import org.commcare.utils.GlobalConstants;
import org.javarosa.core.io.StreamsUtil;
import org.javarosa.core.services.storage.EntityFilter;
import org.javarosa.core.services.storage.IStorageIterator;
import org.javarosa.core.services.storage.Persistable;
import org.javarosa.core.util.InvalidIndexException;
import org.javarosa.core.util.externalizable.Externalizable;

import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Vector;

import javax.crypto.spec.SecretKeySpec;

/**
 * Sql logic for storing persistable objects. Uses the filesystem to store
 * persitables in _encrypted_ manner; useful when objects are larger than the
 * 1mb sql row limit.
 *
 * @author Phillip Mates ([email protected]).
 */
public class HybridFileBackedSqlStorage<T extends Persistable> extends SqlStorage<T> {
    private final File dbDir;
    public static final int ONE_MB_DB_SIZE_LIMIT = 1000000;
    public static final String FIXTURE_STORAGE_TABLE_NAME = "fixture";

    /**
     * Column selection used for reading file data:
     * - Id column needed to correctly set the id of objects read from db,
     *   which isn't set at write time for efficiency.
     * - Data column holds serialized objects under 1mb
     * - File column points to file holding serialized object over 1mb
     * - Aes column holds encryption key for objects saved to filesystem
     *
     * Constraint: we never expect both data and file/aes columns to contain
     * data at the same time
     */
    protected final static String[] dataColumns =
            {DatabaseHelper.ID_COL, DatabaseHelper.DATA_COL,
                    DatabaseHelper.FILE_COL, DatabaseHelper.AES_COL};

    /**
     * Sql object storage layer that stores serialized objects on the filesystem.
     *
     * @param tableName     name of database table
     * @param classType     type of object being stored in this database
     * @param directoryName Name of storage root subdir where entry files are placed
     * @param fsPathBuilder Resolves a db dir path to the correct app dir in external storage
     */
    public HybridFileBackedSqlStorage(String tableName,
                                      Class<? extends T> classType,
                                      AndroidDbHelper dbHelper,
                                      String directoryName,
                                      AppFilePathBuilder fsPathBuilder) {
        super(tableName, classType, dbHelper);
        dbDir = getStorageFile(directoryName, tableName, fsPathBuilder);
        setupDir();
    }

    public static File getStorageFile(String directoryName, String tableName, AppFilePathBuilder fsPathBuilder) {
        String subPath = GlobalConstants.FILE_CC_DB + directoryName + "/_" + tableName;
        return new File(fsPathBuilder.fsPath(subPath));
    }

    private void setupDir() {
        if (!dbDir.exists() && !dbDir.mkdirs()) {
            throw new RuntimeException("Unable to create db storage directory: " + dbDir);
        }
    }

    @Override
    public Vector<T> getRecordsForValues(String[] fieldNames,
                                         Object[] values) {
        SQLiteDatabase db = getDbOrThrow();

        Pair<String, String[]> whereClauseAndArgs =
                helper.createWhereAndroid(fieldNames, values, em, null);

        Cursor cur = db.query(table, dataColumns,
                whereClauseAndArgs.first, whereClauseAndArgs.second,
                null, null, null);
        try {
            Vector<T> recordObjects = new Vector<>();
            if (cur.getCount() > 0) {
                cur.moveToFirst();
                int dataColIndex = cur.getColumnIndexOrThrow(DatabaseHelper.DATA_COL);
                int fileColIndex = cur.getColumnIndexOrThrow(DatabaseHelper.FILE_COL);
                int aesColIndex = cur.getColumnIndexOrThrow(DatabaseHelper.AES_COL);
                int idColIndex = cur.getColumnIndexOrThrow(DatabaseHelper.ID_COL);
                while (!cur.isAfterLast()) {
                    byte[] serializedObj = cur.getBlob(dataColIndex);
                    int dbEntryId = cur.getInt(idColIndex);
                    if (serializedObj != null) {
                        // serialized object was small enough to fit in db entry
                        recordObjects.add(newObject(serializedObj, dbEntryId));
                    } else {
                        // serialized object was stored in filesystem due to large size
                        recordObjects.add(readObjectFromFile(cur, fileColIndex, aesColIndex, dbEntryId));
                    }
                    cur.moveToNext();
                }
            }
            return recordObjects;
        } finally {
            if (cur != null) {
                cur.close();
            }
        }
    }

    private T readObjectFromFile(Cursor cursor, int dbEntryId) {
        return readObjectFromFile(cursor,
                cursor.getColumnIndexOrThrow(DatabaseHelper.FILE_COL),
                cursor.getColumnIndexOrThrow(DatabaseHelper.AES_COL),
                dbEntryId);
    }

    private T readObjectFromFile(Cursor cursor, int fileColIndex,
                                 int aesColIndex, int dbEntryId) {
        String filename = cursor.getString(fileColIndex);
        byte[] aesKeyBlob = cursor.getBlob(aesColIndex);
        InputStream inputStream = null;
        try {
            inputStream = getInputStreamFromFile(filename, aesKeyBlob);
            return newObject(inputStream, dbEntryId);
        } catch (FileNotFoundException e) {
            // TODO PLM: throw runtime or return null?
            throw new RuntimeException(e);
        } finally {
            if (inputStream != null) {
                try {
                    inputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    private SQLiteDatabase getDbOrThrow() {
        return helper.getHandle();
    }

    protected InputStream getInputStreamFromFile(String filename, byte[] aesKeyBytes) throws FileNotFoundException {
        SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES");
        return EncryptionIO.getFileInputStream(filename, aesKey);
    }

    @Override
    public T getRecordForValues(String[] rawFieldNames, Object[] values)
            throws NoSuchElementException, InvalidIndexException {
        SQLiteDatabase db = getDbOrThrow();

        Pair<String, String[]> whereArgsAndVals =
                helper.createWhereAndroid(rawFieldNames, values, em, null);
        Cursor cur = db.query(table, dataColumns,
                whereArgsAndVals.first, whereArgsAndVals.second,
                null, null, null);
        try {
            int queryCount = cur.getCount();
            if (queryCount == 0) {
                throw new NoSuchElementException("No element in table " + table +
                        " with names " + Arrays.toString(rawFieldNames) +
                        " and values " + Arrays.toString(values));
            } else if (queryCount > 1) {
                throw new InvalidIndexException("Invalid unique column set" +
                        Arrays.toString(rawFieldNames) +
                        ". Multiple records found with value " +
                        Arrays.toString(values), Arrays.toString(rawFieldNames));
            }
            cur.moveToFirst();
            byte[] serializedObj = cur.getBlob(cur.getColumnIndexOrThrow(DatabaseHelper.DATA_COL));
            int dbEntryId = cur.getInt(cur.getColumnIndexOrThrow(DatabaseHelper.ID_COL));
            if (serializedObj != null) {
                return newObject(serializedObj, dbEntryId);
            } else {
                return readObjectFromFile(cur, dbEntryId);
            }
        } finally {
            if (cur != null) {
                cur.close();
            }
        }
    }

    @Override
    public T getRecordForValue(String rawFieldName, Object value)
            throws NoSuchElementException, InvalidIndexException {
        return getRecordForValues(new String[]{rawFieldName}, new Object[]{value});
    }

    @Override
    public byte[] readBytes(int id) {
        Cursor cur = getDbOrThrow().query(table, dataColumns,
                DatabaseHelper.ID_COL + "=?",
                new String[]{String.valueOf(id)}, null, null, null);

        InputStream is = null;
        try {
            cur.moveToFirst();
            byte[] serializedObj = cur.getBlob(cur.getColumnIndexOrThrow(DatabaseHelper.DATA_COL));
            if (serializedObj != null) {
                return serializedObj;
            } else {
                String filename = cur.getString(cur.getColumnIndexOrThrow(DatabaseHelper.FILE_COL));
                byte[] aesKeyBlob = cur.getBlob(cur.getColumnIndexOrThrow(DatabaseHelper.AES_COL));
                is = getInputStreamFromFile(filename, aesKeyBlob);

                return StreamsUtil.inputStreamToByteArray(is);
            }
        } catch (IOException e) {
            throw new RuntimeException("Unable to read serialized object from file.", e);
        } finally {
            if (is != null) {
                try {
                    is.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (cur != null) {
                cur.close();
            }
        }
    }

    @Override
    public void write(Persistable persistable) {
        if (persistable.getID() != -1) {
            update(persistable.getID(), persistable);
            return;
        }
        SQLiteDatabase db = getDbOrThrow();

        boolean startedTransaction = false;

        try {
            long insertedId;
            ByteArrayOutputStream bos = writeExternalizableToStream(persistable);
            String dataFilePath = null;
            try {
                if (blobFitsInDb(bos)) {
                    db.beginTransaction();
                    startedTransaction = true;
                    // serialized object small enough to fit in db
                    ContentValues contentValues = helper.getNonDataContentValues(persistable);
                    contentValues.put(DatabaseHelper.DATA_COL, bos.toByteArray());
                    insertedId = db.insertOrThrow(table, DatabaseHelper.DATA_COL, contentValues);
                } else {
                    // store serialized object in file and file pointer in db
                    dataFilePath = HybridFileBackedSqlHelpers.newFileForEntry(dbDir).getAbsolutePath();
                    HybridFileBackedSqlHelpers.setFileAsOrphan(db, dataFilePath);

                    db.beginTransaction();
                    startedTransaction = true;

                    ContentValues contentValues = helper.getNonDataContentValues(persistable);
                    contentValues.put(DatabaseHelper.FILE_COL, dataFilePath);
                    byte[] key = generateKeyAndAdd(contentValues);
                    insertedId = db.insertOrThrow(table, DatabaseHelper.FILE_COL, contentValues);

                    writeStreamToFile(bos, dataFilePath, key);
                }
            } finally {
                bos.close();
            }
            // won't effect already stored obj id, which is set when reading out of db.
            // rather, needed in case persistable object is used after being written to storage.
            persistable.setID((int)insertedId);

            if (insertedId > Integer.MAX_VALUE) {
                throw new RuntimeException("Waaaaaaaaaay too many values");
            }

            if (dataFilePath != null) {
                HybridFileBackedSqlHelpers.unsetFileAsOrphan(db, dataFilePath);
            }
            if(startedTransaction) {
                db.setTransactionSuccessful();
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        } finally {
            if (startedTransaction) {
                db.endTransaction();
            }
        }
    }

    private ByteArrayOutputStream writeExternalizableToStream(Externalizable extObj) {
        ByteArrayOutputStream bos = new ByteArrayOutputStream();
        try {
            extObj.writeExternal(new DataOutputStream(bos));
        } catch (IOException e) {
            throw new RuntimeException("Failed to serialize externalizable", e);
        }
        return bos;
    }

    protected boolean blobFitsInDb(ByteArrayOutputStream blobStream) {
        return blobStream.size() < ONE_MB_DB_SIZE_LIMIT;
    }

    protected byte[] generateKeyAndAdd(ContentValues contentValues) {
        byte[] key = CommCareApplication.instance().createNewSymmetricKey().getEncoded();
        contentValues.put(DatabaseHelper.AES_COL, key);
        return key;
    }

    private void writeStreamToFile(ByteArrayOutputStream bos, String filename,
                                   byte[] key) throws IOException {
        DataOutputStream fileOutputStream = null;
        try {
            fileOutputStream = getOutputFileStream(filename, key);
            bos.writeTo(fileOutputStream);
        } finally {
            if (fileOutputStream != null) {
                try {
                    fileOutputStream.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    protected DataOutputStream getOutputFileStream(String filename,
                                                   byte[] aesKeyBytes) throws IOException {
        SecretKeySpec aesKey = new SecretKeySpec(aesKeyBytes, "AES");
        return new DataOutputStream(EncryptionIO.createFileOutputStream(filename, aesKey));
    }

    @Override
    public int add(Externalizable externalizable) {
        throw new UnsupportedOperationException("Use 'SqlFileBackedStorage.write'");
    }

    @Override
    public void update(int id, Externalizable extObj) {
        SQLiteDatabase db = getDbOrThrow();

        ByteArrayOutputStream bos = null;
        boolean startedTransaction = false;

        try {
            Pair<String, byte[]> filenameAndKey =
                    HybridFileBackedSqlHelpers.getEntryFilenameAndKey(helper, table, id);
            String filename = filenameAndKey.first;
            byte[] fileEncryptionKey = filenameAndKey.second;
            boolean objectInDb = (filename == null);

            bos = writeExternalizableToStream(extObj);
            if (blobFitsInDb(bos)) {
                db.beginTransaction();
                startedTransaction = true;
                updateEntryToStoreInDb(extObj, objectInDb, filename, bos, db, id);
            } else {
                String newFilePath = HybridFileBackedSqlHelpers.newFileForEntry(dbDir).getAbsolutePath();
                HybridFileBackedSqlHelpers.setFileAsOrphan(db, newFilePath);

                db.beginTransaction();
                startedTransaction = true;
                updateEntryToStoreInFs(extObj, objectInDb, filename,
                        newFilePath, fileEncryptionKey, bos, db, id);
            }

            if(startedTransaction) {
                db.setTransactionSuccessful();
            }
        } catch (IOException e) {
            throw new RuntimeException("Unable update db entry to store data in filesystem", e);
        } finally {
            if (bos != null) {
                try {
                    bos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if(startedTransaction) {
                db.endTransaction();
            }
        }
    }

    private void updateEntryToStoreInDb(Externalizable extObj, boolean objectInDb,
                                        String filename, ByteArrayOutputStream bos,
                                        SQLiteDatabase db, int id) {
        ContentValues updatedContentValues =
                helper.getContentValuesWithCustomData(extObj, bos.toByteArray());
        if (!objectInDb) {
            // was stored in file: remove file and store in db
            updatedContentValues.put(DatabaseHelper.FILE_COL, (String)null);
            updatedContentValues.put(DatabaseHelper.AES_COL, (byte[])null);

            HybridFileBackedSqlHelpers.setFileAsOrphan(db, filename);
        }
        db.update(table, updatedContentValues,
                DatabaseHelper.ID_COL + "=?", new String[]{String.valueOf(id)});
    }

    private void updateEntryToStoreInFs(Externalizable extObj, boolean objectInDb,
                                        String currentFilePath, String newFilePath,
                                        byte[] fileEncryptionKey,
                                        ByteArrayOutputStream bos,
                                        SQLiteDatabase db,
                                        int id) throws IOException {
        ContentValues updatedContentValues;
        if (objectInDb) {
            // was in db but is now to big, null db data entry and write to file
            updatedContentValues = helper.getContentValuesWithCustomData(extObj, null);
            updatedContentValues.put(DatabaseHelper.FILE_COL, newFilePath);
            fileEncryptionKey = generateKeyAndAdd(updatedContentValues);
        } else {
            // was stored in a file all along, atomically update file by
            // writing to new and removing old file
            updatedContentValues = helper.getNonDataContentValues(extObj);
            updatedContentValues.put(DatabaseHelper.FILE_COL, newFilePath);

            HybridFileBackedSqlHelpers.setFileAsOrphan(db, currentFilePath);
        }
        db.update(table, updatedContentValues,
                DatabaseHelper.ID_COL + "=?", new String[]{String.valueOf(id)});

        writeStreamToFile(bos, newFilePath, fileEncryptionKey);
        HybridFileBackedSqlHelpers.unsetFileAsOrphan(db, newFilePath);
    }

    @Override
    public void remove(int id) {
        SQLiteDatabase db = getDbOrThrow();

        String filename = HybridFileBackedSqlHelpers.getEntryFilename(helper, table, id);
        db.beginTransaction();
        try {
            db.delete(table, DatabaseHelper.ID_COL + "=?", new String[]{String.valueOf(id)});
            db.setTransactionSuccessful();
        } finally {
            db.endTransaction();
        }

        if (filename != null) {
            File dataFile = new File(filename);
            dataFile.delete();
        }
    }

    @Override
    public void remove(List<Integer> ids) {
        if (ids.size() > 0) {
            SQLiteDatabase db = getDbOrThrow();
            List<String> filesToRemove;
            db.beginTransaction();
            try {
                filesToRemove = HybridFileBackedSqlHelpers.getFilesToRemove(ids, helper, table);
                List<Pair<String, String[]>> whereParamList = TableBuilder.sqlList(ids);
                for (Pair<String, String[]> whereParams : whereParamList) {
                    String whereClause = DatabaseHelper.ID_COL + " IN " + whereParams.first;
                    db.delete(table, whereClause, whereParams.second);
                }
                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }

            HybridFileBackedSqlHelpers.removeFiles(filesToRemove);
        }
    }

    @Override
    public void removeAll() {
        wipeTable(getDbOrThrow(), table);
        FileUtil.deleteFileOrDir(dbDir);
    }

    @Override
    public Vector<Integer> removeAll(EntityFilter ef) {
        Vector<Integer> removed = new Vector<>();
        for (IStorageIterator iterator = this.iterate(); iterator.hasMore(); ) {
            int id = iterator.nextID();
            switch (ef.preFilter(id, null)) {
                case EntityFilter.PREFILTER_INCLUDE:
                    removed.add(id);
                    continue;
                case EntityFilter.PREFILTER_EXCLUDE:
                    continue;
                case EntityFilter.PREFILTER_FILTER:
                    if (ef.matches(read(id))) {
                        removed.add(id);
                    }
            }
        }

        if (removed.size() > 0) {
            List<Pair<String, String[]>> whereParamList =
                    TableBuilder.sqlList(removed);

            SQLiteDatabase db = getDbOrThrow();

            List<String> filesToRemove;
            db.beginTransaction();
            try {
                filesToRemove = HybridFileBackedSqlHelpers.getFilesToRemove(removed, helper, table);
                for (Pair<String, String[]> whereParams : whereParamList) {
                    String whereClause = DatabaseHelper.ID_COL + " IN " + whereParams.first;
                    db.delete(table, whereClause, whereParams.second);
                }

                db.setTransactionSuccessful();
            } finally {
                db.endTransaction();
            }

            HybridFileBackedSqlHelpers.removeFiles(filesToRemove);
        }

        return removed;
    }

    @Override
    public SqlStorageIterator<T> iterate(boolean includeData) {
        SQLiteDatabase db = getDbOrThrow();

        SqlStorageIterator<T> spanningIterator =
                getIndexSpanningIteratorOrNull(db, includeData);
        if (spanningIterator != null) {
            return spanningIterator;
        } else {
            return new HybridFileBackedStorageIterator<>(getIterateCursor(db, includeData), this);
        }
    }

    @Override
    protected Cursor getIterateCursor(SQLiteDatabase db, boolean includeData) {
        if (includeData) {
            return db.query(table, dataColumns, null, null, null, null, null);
        } else {
            return db.query(table, new String[]{DatabaseHelper.ID_COL},
                    null, null, null, null, null);
        }
    }

    @Override
    public SqlStorageIterator<T> iterate(boolean includeData, String[] metadataKeys) {
        throw new UnsupportedOperationException("iterate method unsupported");
    }

    /**
     * For testing only
     */
    public File getDbDirForTesting() {
        return dbDir;
    }

    /**
     * For testing only
     */
    public String getEntryFilenameForTesting(int id) {
        return HybridFileBackedSqlHelpers.getEntryFilename(helper, table, id);
    }
}