package com.uriio.beacons;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.database.sqlite.SQLiteStatement;
import android.os.Build;
import android.support.annotation.NonNull;
import android.util.Base64;
import android.util.SparseArray;

import com.uriio.beacons.ble.Advertiser;
import com.uriio.beacons.model.Beacon;
import com.uriio.beacons.model.EddystoneBase;
import com.uriio.beacons.model.EddystoneEID;
import com.uriio.beacons.model.EddystoneTLM;
import com.uriio.beacons.model.EddystoneUID;
import com.uriio.beacons.model.EddystoneURL;
import com.uriio.beacons.model.iBeacon;

import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.Locale;

/**
 * Database manager.
 */
public class Storage extends SQLiteOpenHelper {
    /**
     * Serializer/storage interface for custom beacon kinds.
     */
    public interface Persistable {
        /**
         * @return The beacon kind. Values below 0x10000 (65536) are reserved. Since the beacon kind
         * is stored with every beacon, it must never be changed.
         */
        int getKind();

        /**
         * Prepares an insert. You can bind custom data to indexes 1 to 7 of the statement.
         * @param beacon       Beacon that will be inserted.
         * @param statement    Compiled statement. First 7 positions can be used for custom data.
         */
        void prepareInsert(Beacon beacon, SQLiteStatement statement);

        /**
         * @param beacon    Beacon to be saved.
         * @param db        SQLite database
         * @param flags     Custom flags sent by your beacon editor, or 0 to save default data.  @return          The statement to use. The statement is not closed after execution.
         */
        SQLiteStatement prepareUpdate(Beacon beacon, SQLiteDatabase db, int flags);

        /**
         * Called after a beacon was deleted from storage.
         * @param beacon    The beacon that was removed.
         */
        void onDeleted(Beacon beacon);

        /**
         * @param cursor    Database cursor.
         * @return          Deserialized beacon instance.
         */
        Beacon fromCursor(Cursor cursor);

        void close();
    }

    public static final int KIND_EDDYSTONE_URL = 1;
    public static final int KIND_EDDYSTONE_UID = 2;
    public static final int KIND_IBEACON       = 3;
    public static final int KIND_EDDYSTONE_EID = 4;
    public static final int KIND_EDDYSTONE_TLM = 5;

    private static final String ITEMS_TABLE     = "b";
    @Deprecated private static final String EDDYSTONE_TABLE = "url";
    @Deprecated private static final String IBEACONS_TABLE  = "ib";
    @Deprecated private static final String URIIO_TABLE     = "uriio";

    private static final int DATABASE_SCHEMA_VERSION = 7;

    private static Storage _instance;

    /** lazy SQLite statements **/
    private SQLiteStatement mInsertItemStmt = null;
    private SQLiteStatement mUpdateItemStmt = null;
    private SQLiteStatement mDeleteItemStmt = null;
    private SQLiteStatement mUpdateStateStmt = null;
    private SQLiteStatement mUpdateEddystoneStmt = null;
    private SQLiteStatement mUpdateIBeaconStmt = null;

    private SparseArray<Persistable> mBeaconPersisters = null;

    private Storage(Context context, String dbName) {
        super(context, dbName, null, DATABASE_SCHEMA_VERSION);
    }

    static void init(Context context, String dbName) {
        _instance = new Storage(context, dbName);

        ApplicationInfo appInfo;
        try {
            appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA);
        } catch (PackageManager.NameNotFoundException e) {
            throw new IllegalArgumentException("App package not found");
        }

        // metadata is null when no entries exist
        if (null != appInfo && null != appInfo.metaData) {
            for (String key : appInfo.metaData.keySet()) {
                if (key.startsWith("com.uriio.ext.")) {
                    String className = appInfo.metaData.getString(key);
                    if (null != className) {
                        Persistable persistable;

                        try {
                            persistable = (Persistable) Class.forName(className).newInstance();
                        } catch (Exception ignored) {
                            continue;
                        }

                        int kind = persistable.getKind();
                        if (kind > 0xffff) {
                            if (null == _instance.mBeaconPersisters) {
                                _instance.mBeaconPersisters = new SparseArray<>();
                            }
                            _instance.mBeaconPersisters.put(kind, persistable);
                        }
                    }
                }
            }
        }
    }

    public static Storage getInstance() {
        return _instance;
    }

    @Override
    public void onCreate(SQLiteDatabase db) {
//        db.beginTransaction();

        // create main table
        db.execSQL("CREATE TABLE " + ITEMS_TABLE + " (kind INTEGER, created INTEGER, " +
                "advMode INTEGER, txLevel INTEGER, state INTEGER DEFAULT 1, flags INTEGER, " +
                "name TEXT, d0 TEXT, d1 TEXT, d2 TEXT, d3 TEXT, d4 TEXT, d5 TEXT, d6 TEXT)");

        // indexes
//        db.execSQL("CREATE INDEX ia ON " + ITEMS_TABLE + "(kind)");

//        db.setTransactionSuccessful();
//        db.endTransaction();
    }

    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        if (oldVersion < 2) {
            // added the 'name' column to items
            db.execSQL("ALTER TABLE " + ITEMS_TABLE + " ADD COLUMN name TEXT");
        }

        if (oldVersion < 3) {
            // added the 'domain' column to Eddystone items
            db.execSQL("ALTER TABLE " + EDDYSTONE_TABLE + " ADD COLUMN domain TEXT");
        }

        if (oldVersion < 6) {
            db.execSQL("ALTER TABLE " + EDDYSTONE_TABLE + " ADD COLUMN lockKey BLOB");
        }

        if (oldVersion < 7) {
            for (int idx = 0; idx < 7; idx++) {
                db.execSQL(String.format(Locale.US, "ALTER TABLE " + ITEMS_TABLE + " ADD COLUMN d%d TEXT", idx));
            }

            migrateUriioItems(db);
            migrateEddystoneItems(db);
            migrateIBeaconItems(db);
        }
    }

    @Override
    public synchronized void close() {
        if (null != mInsertItemStmt) {
            mInsertItemStmt.close();
            mInsertItemStmt = null;
        }

        if (null != mUpdateItemStmt) {
            mUpdateItemStmt.close();
            mUpdateItemStmt = null;
        }

        if (null != mDeleteItemStmt) {
            mDeleteItemStmt.close();
            mDeleteItemStmt = null;
        }

        if (null != mUpdateStateStmt) {
            mUpdateStateStmt.close();
            mUpdateStateStmt = null;
        }

        if (null != mUpdateEddystoneStmt) {
            mUpdateEddystoneStmt.close();
            mUpdateEddystoneStmt = null;
        }

        if (null != mUpdateIBeaconStmt) {
            mUpdateIBeaconStmt.close();
            mUpdateIBeaconStmt = null;
        }

        if (null != mBeaconPersisters) {
            for (int idx = mBeaconPersisters.size() - 1; idx >= 0; --idx) {
                mBeaconPersisters.valueAt(idx).close();
            }
        }

        super.close();
    }

    public long insert(Beacon item) {
        SQLiteDatabase db = getWritableDatabase();

        if (null == mInsertItemStmt) {
            mInsertItemStmt = db.compileStatement("INSERT INTO " + ITEMS_TABLE +
                    " (d0, d1, d2, d3, d4, d5, d6, created, advMode, txLevel, kind, flags, name)" +
                    " VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)");
        }

        mInsertItemStmt.clearBindings();

        switch (item.getKind()) {
            case KIND_EDDYSTONE_URL:
            case KIND_EDDYSTONE_UID:
            case KIND_EDDYSTONE_EID:
            case KIND_EDDYSTONE_TLM:
                bindInsertEddystoneItem((EddystoneBase) item);
                break;
            case KIND_IBEACON:
                bindInsertIBeaconStatement((iBeacon) item);
                break;
            default:
                if (null != mBeaconPersisters) {
                    mBeaconPersisters.get(item.getKind()).prepareInsert(item, mInsertItemStmt);
                }
                break;
        }

        mInsertItemStmt.bindLong(8, System.currentTimeMillis());
        mInsertItemStmt.bindLong(9, item.getAdvertiseMode());
        mInsertItemStmt.bindLong(10, item.getTxPowerLevel());
        mInsertItemStmt.bindLong(11, item.getKind());
        mInsertItemStmt.bindLong(12, item.getFlags());
        bindStringOrNull(mInsertItemStmt, 13, item.getName());

        long rowid = mInsertItemStmt.executeInsert();
        if (rowid > 0) {
            item.setStorageId(rowid);
        }

        return rowid;
    }

    private void bindInsertEddystoneItem(EddystoneBase beacon) {
        mInsertItemStmt.bindBlob(1, beacon.getLockKey());

        switch (beacon.getKind()) {
            case KIND_EDDYSTONE_URL:
                bindStringOrNull(mInsertItemStmt, 2, ((EddystoneURL) beacon).getURL());
                break;
            case KIND_EDDYSTONE_UID:
                mInsertItemStmt.bindBlob(2, ((EddystoneUID) beacon).getNamespaceInstance());
                bindStringOrNull(mInsertItemStmt, 3, ((EddystoneUID) beacon).getDomainHint());
                break;
            case KIND_EDDYSTONE_EID:
                mInsertItemStmt.bindBlob(2, ((EddystoneEID) beacon).getIdentityKey());
                mInsertItemStmt.bindLong(3, ((EddystoneEID) beacon).getRotationExponent());
                mInsertItemStmt.bindLong(4, ((EddystoneEID) beacon).getClockOffset());
                break;
            case KIND_EDDYSTONE_TLM:
                mInsertItemStmt.bindLong(2, ((EddystoneTLM) beacon).getRefreshInterval());
                break;
        }
    }

    private void bindInsertIBeaconStatement(iBeacon beacon) {
        mInsertItemStmt.bindBlob(1, beacon.getUuidRaw());
        mInsertItemStmt.bindLong(2, beacon.getMajor());
        mInsertItemStmt.bindLong(3, beacon.getMinor());
    }

    public void delete(Beacon beacon) {
        long id = beacon.getSavedId();
        if (id > 0) {
            SQLiteDatabase db = getWritableDatabase();

            if (null == mDeleteItemStmt) {
                mDeleteItemStmt = db.compileStatement("DELETE FROM " + ITEMS_TABLE + " WHERE rowid=?");
            }

            mDeleteItemStmt.bindLong(1, id);
            executeSafeUpdateOrDelete(mDeleteItemStmt);

            Persistable persistable = null == mBeaconPersisters ? null : mBeaconPersisters.get(beacon.getKind());
            if (null != persistable) {
                persistable.onDeleted(beacon);
            }
        }
    }

    Cursor queryAll(boolean stopped) {
        // if we ever use this in a CursorAdapter, the rowid column should be aliased to '_id'
        return getReadableDatabase().rawQuery(String.format("SELECT d0, d1, d2, d3, d4, d5, d6," +
                        " rowid, state, advMode, txLevel, flags, kind, name, created" +
                        " FROM " + ITEMS_TABLE + " WHERE state%s2 ORDER BY rowid DESC",
                stopped ? "=" : "<"), null);
    }

    Cursor query(long itemId) {
        return getReadableDatabase().rawQuery("SELECT d0, d1, d2, d3, d4, d5, d6," +
                        " rowid, state, advMode, txLevel, flags, kind, name, created" +
                        " FROM " + ITEMS_TABLE + " WHERE rowid=?",
                new String[] { String.valueOf(itemId)});
    }

    public static long getId(Cursor cursor) {
        return cursor.getLong(7);
    }

    public static int getKind(Cursor cursor) {
        return cursor.getInt(12);
    }

    public static Beacon fromCursor(Cursor cursor) {
        Beacon beacon;
        int kind = cursor.getInt(12);

        switch (kind) {
            case KIND_EDDYSTONE_URL:
                beacon = new EddystoneURL(cursor.getString(1),
                        cursor.isNull(0) ? null : cursor.getBlob(0));
                break;
            case KIND_EDDYSTONE_UID:
                beacon = new EddystoneUID(cursor.getBlob(1), cursor.getString(2),
                        cursor.isNull(0) ? null : cursor.getBlob(0), null);
                break;
            case KIND_EDDYSTONE_EID:
                beacon = new EddystoneEID(cursor.getBlob(1), (byte) cursor.getInt(2), cursor.getInt(3),
                        cursor.isNull(0) ? null : cursor.getBlob(0));
                break;
            case KIND_EDDYSTONE_TLM:
                beacon = new EddystoneTLM(cursor.getInt(1), cursor.isNull(0) ? null : cursor.getBlob(0));
                break;
            case KIND_IBEACON:
                beacon = new iBeacon(cursor.getBlob(0), cursor.getInt(1), cursor.getInt(2));
                break;
            default:
                Persistable persistable = null == getInstance().mBeaconPersisters ? null : getInstance().mBeaconPersisters.get(kind);
                beacon = null == persistable ? null : persistable.fromCursor(cursor);
                break;
        }

        if (null != beacon) {
            long itemId = cursor.getLong(7);
            @Advertiser.Mode int advertiseMode = cursor.getInt(9);
            @Advertiser.Power int txPowerLevel = cursor.getInt(10);
            int flags = cursor.getInt(11);
            String name = cursor.getString(13);

            beacon.init(itemId, advertiseMode, txPowerLevel, flags, name);
            beacon.setActiveState(cursor.getInt(8));
        }

        return beacon;
    }

    private SQLiteStatement prepareUpdateStatement(iBeacon item, SQLiteDatabase db) {
        if (null == mUpdateIBeaconStmt) {
            mUpdateIBeaconStmt = createUpdater(db, "d0", "d1", "d2");
        }
        mUpdateIBeaconStmt.bindBlob(2, item.getUuidRaw());
        mUpdateIBeaconStmt.bindLong(3, item.getMajor());
        mUpdateIBeaconStmt.bindLong(4, item.getMinor());

        return mUpdateIBeaconStmt;
    }

    private SQLiteStatement prepareUpdateStatement(EddystoneBase beacon, SQLiteDatabase db) {
        if (null == mUpdateEddystoneStmt) {
            mUpdateEddystoneStmt = createUpdater(db, "d0", "d1", "d2", "d3");
        }

        mUpdateEddystoneStmt.bindBlob(2, beacon.getLockKey());

        switch (beacon.getKind()) {
            case KIND_EDDYSTONE_URL:
                bindStringOrNull(mUpdateEddystoneStmt, 3, ((EddystoneURL) beacon).getURL());
                break;
            case KIND_EDDYSTONE_UID:
                mUpdateEddystoneStmt.bindBlob(3, ((EddystoneUID) beacon).getNamespaceInstance());
                bindStringOrNull(mUpdateEddystoneStmt, 4, ((EddystoneUID) beacon).getDomainHint());
                break;
            case KIND_EDDYSTONE_EID:
                mUpdateEddystoneStmt.bindBlob(3, ((EddystoneEID) beacon).getIdentityKey());
                mUpdateEddystoneStmt.bindLong(4, ((EddystoneEID) beacon).getRotationExponent());
                mUpdateEddystoneStmt.bindLong(5, ((EddystoneEID) beacon).getClockOffset());
                break;
            case KIND_EDDYSTONE_TLM:
                mUpdateEddystoneStmt.bindLong(3, ((EddystoneTLM) beacon).getRefreshInterval());
                break;
        }

        return mUpdateEddystoneStmt;
    }

    /**
     * Saves an existing beacon's main details, and/or custom details.
     * <b>This method is for internal (and beacon extensions) use only.</b>
     * @param beacon    An existing beacon.
     * @param flags     If 0, the beacon's <b>advertiseMode</b>, <b>txPower</b>, <b>name</b> and <b>flags</b> will be saved.
     *                  Other basic details will also be saved depending on the beacon type.
     *                  If non-zero, then only custom details will be saved, on a per-beacon defined basis.
     */
    public void update(Beacon beacon, int flags) {
        SQLiteDatabase db = getWritableDatabase();

        if (0 == flags) {
            // we'll do two updates - use a transaction
            db.beginTransaction();

            if (null == mUpdateItemStmt) {
                mUpdateItemStmt = db.compileStatement("UPDATE " + ITEMS_TABLE + " SET advMode=?, txLevel=?, flags=?, name=? WHERE rowid=?");
            }

            mUpdateItemStmt.bindLong(1, beacon.getAdvertiseMode());
            mUpdateItemStmt.bindLong(2, beacon.getTxPowerLevel());
            mUpdateItemStmt.bindLong(3, beacon.getFlags());
            bindStringOrNull(mUpdateItemStmt, 4, beacon.getName());
            mUpdateItemStmt.bindLong(5, beacon.getSavedId());

            executeSafeUpdateOrDelete(mUpdateItemStmt);
        }

        SQLiteStatement updateStatement;
        switch (beacon.getKind()) {
            case KIND_EDDYSTONE_URL:
            case KIND_EDDYSTONE_UID:
            case KIND_EDDYSTONE_EID:
            case KIND_EDDYSTONE_TLM:
                updateStatement = prepareUpdateStatement((EddystoneBase) beacon, db);
                break;
            case KIND_IBEACON:
                updateStatement = prepareUpdateStatement((iBeacon) beacon, db);
                break;
            default:
                Persistable persister = null == mBeaconPersisters ? null : mBeaconPersisters.get(beacon.getKind());
                updateStatement = null == persister ? null : persister.prepareUpdate(beacon, db, flags);
                break;
        }

        if (null != updateStatement) {
            updateStatement.bindLong(1, beacon.getSavedId());
            executeSafeUpdateOrDelete(updateStatement);
        }

        if (0 == flags) {
            db.setTransactionSuccessful();
            db.endTransaction();
        }
    }

    /**
     * Saves an existing beacon's main details.
     * @param beacon    Target beacon.
     */
    public void update(Beacon beacon) {
        update(beacon, 0);
    }

    public void updateState(Beacon beacon, int state) {
        SQLiteDatabase db = getWritableDatabase();

        if (null == mUpdateStateStmt) {
            mUpdateStateStmt = db.compileStatement("UPDATE " + ITEMS_TABLE + " SET state=? WHERE rowid=?");
        }

        mUpdateStateStmt.bindLong(1, state);
        mUpdateStateStmt.bindLong(2, beacon.getSavedId());

        executeSafeUpdateOrDelete(mUpdateStateStmt);
    }

    /**
     * Binds either a string or NULL to a SQLite statement.
     * Reason: trying to bind a null string would normally crash the app.
     * @param statement    SQLite statement
     * @param value        A string, or null
     */
    public static void bindStringOrNull(@NonNull SQLiteStatement statement, int index, String value) {
        if (null == value) {
            statement.bindNull(index);
        }
        else {
            statement.bindString(index, value);
        }
    }

    @SuppressLint("ObsoleteSdkInt")
    private void executeSafeUpdateOrDelete(SQLiteStatement statement) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.HONEYCOMB) {
            statement.executeUpdateDelete();
        }
        else {
            statement.execute();
        }
    }

    private static @NonNull SQLiteStatement createUpdater(SQLiteDatabase db, String... columns) {
        StringBuilder sql = null;

        for (int i = 0; i < columns.length; ++i) {
            String column = columns[i];
            if (null == sql) {
                sql = new StringBuilder("UPDATE " + ITEMS_TABLE + " SET ");
            } else {
                sql.append(",");
            }
            sql.append(column).append("=?").append(2 + i);
        }

        if (null == sql) {
            throw new IllegalArgumentException("Invalid columns");
        }

        // need a db reference to work around IllegalStateException: getDatabase called recursively
        return db.compileStatement(sql.append(" WHERE rowid=?1").toString());
    }

    /**
     * Creates a beacon update statement for saving custom data.
     * In the returned statement, bind position 1 is reserved for the ID of the updated item.
     * @param db            SQLite database
     * @param columns       Custom columns to update, 0 to 6 inclusive.
     * @return              A compiled statement. To bind values, start from index 2.
     */
    public static @NonNull SQLiteStatement createUpdater(SQLiteDatabase db, int... columns) {
        String[] names = new String[columns.length];
        for (int i = 0; i < columns.length; ++i) {
            if (columns[i] >= 0 && columns[i] < 7) {
                names[i] = "d" + columns[i];
            }
        }
        return createUpdater(db, names);
    }

    private void migrateUriioItems(SQLiteDatabase db) {
        SQLiteStatement updateStatement = createUpdater(db, "d1", "d5", "kind");
        Cursor cursor = db.rawQuery("SELECT rowid, longUrl, shortUrl FROM " + URIIO_TABLE, null);
        while (cursor.moveToNext()) {
            updateStatement.bindLong(1, cursor.getLong(0));
            bindStringOrNull(updateStatement, 2, cursor.getString(1));
            bindStringOrNull(updateStatement, 3, cursor.getString(2));
            updateStatement.bindLong(4, 0x10000);

            executeSafeUpdateOrDelete(updateStatement);
        }
        cursor.close();
        updateStatement.close();

        db.execSQL("DROP TABLE " + URIIO_TABLE);
    }

    private void migrateIBeaconItems(SQLiteDatabase db) {
        SQLiteStatement updateStatement = createUpdater(db, "d0", "d1", "d2");
        Cursor cursor = db.rawQuery("SELECT rowid, uuid, maj, min FROM " + IBEACONS_TABLE, null);
        while (cursor.moveToNext()) {
            updateStatement.bindLong(1, cursor.getLong(0));
            updateStatement.bindBlob(2, Base64.decode(cursor.getString(1), Base64.DEFAULT));
            updateStatement.bindLong(3, cursor.getInt(2));
            updateStatement.bindLong(4, cursor.getInt(3));

            executeSafeUpdateOrDelete(updateStatement);
        }
        cursor.close();
        updateStatement.close();

        db.execSQL("DROP TABLE " + IBEACONS_TABLE);
    }

    private void migrateEddystoneItems(SQLiteDatabase db) {
        SQLiteStatement updateStatement = createUpdater(db, "d0", "d1", "d2", "d3", "kind", "flags");
        Cursor cursor = db.rawQuery("SELECT e.rowid, url, domain, lockKey, flags" +
                " FROM " + EDDYSTONE_TABLE + " e" +
                " LEFT OUTER JOIN " + ITEMS_TABLE + " i ON e.rowid=i.rowid", null);
        while (cursor.moveToNext()) {
            updateStatement.clearBindings();

            updateStatement.bindLong(1, cursor.getLong(0));

            int flags = cursor.getInt(4);
            int type = flags >>> 4;

            if (!cursor.isNull(3)) {
                updateStatement.bindBlob(2, cursor.getBlob(3));   // lock key
            }
            updateStatement.bindLong(7, 0);

            String payload = cursor.getString(1);
            String domain = cursor.getString(2);

            switch (type) {
                case 0:         // EDDYSTONE_URL
                    bindStringOrNull(updateStatement, 3, payload);
                    updateStatement.bindLong(6, KIND_EDDYSTONE_URL);
                    break;
                case 1:         // EDDYSTONE_UID
                    updateStatement.bindBlob(3, Base64.decode(payload, Base64.DEFAULT));
                    bindStringOrNull(updateStatement, 4, domain);
                    updateStatement.bindLong(6, KIND_EDDYSTONE_UID);
                    break;
                case 2:         // EDDYSTONE_EID
                    byte[] eidRaw = Base64.decode(payload, Base64.DEFAULT);
                    int eidTimeOffset = ByteBuffer.wrap(eidRaw, 16, 4).getInt();

                    // sanitize time offset to match range; see EIDUtils.register()
                    eidTimeOffset = Math.min(255, Math.max(-65280, eidTimeOffset));

                    // sanitize rotation exponent to [0, 15] range
                    byte rotationExponent = (byte) (eidRaw[20] & 0x0f);

                    updateStatement.bindBlob(3, Arrays.copyOfRange(eidRaw, 0, 16));
                    updateStatement.bindLong(4, rotationExponent);
                    updateStatement.bindLong(5, eidTimeOffset);
                    updateStatement.bindLong(6, KIND_EDDYSTONE_EID);
                    break;
            }

            executeSafeUpdateOrDelete(updateStatement);
        }
        cursor.close();
        updateStatement.close();

        db.execSQL("DROP TABLE " + EDDYSTONE_TABLE);
    }
}