/*
 * Copyright (C) 2020-2021  Yomichan Authors
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 */

class Database {
    constructor() {
        this._db = null;
        this._isOpening = false;
    }

    // Public

    async open(databaseName, version, structure) {
        if (this._db !== null) {
            throw new Error('Database already open');
        }
        if (this._isOpening) {
            throw new Error('Already opening');
        }

        try {
            this._isOpening = true;
            this._db = await this._open(databaseName, version, (db, transaction, oldVersion) => {
                this._upgrade(db, transaction, oldVersion, structure);
            });
        } finally {
            this._isOpening = false;
        }
    }

    close() {
        if (this._db === null) {
            throw new Error('Database is not open');
        }

        this._db.close();
        this._db = null;
    }

    isOpening() {
        return this._isOpening;
    }

    isOpen() {
        return this._db !== null;
    }

    transaction(storeNames, mode) {
        if (this._db === null) {
            throw new Error(this._isOpening ? 'Database not ready' : 'Database not open');
        }
        return this._db.transaction(storeNames, mode);
    }

    bulkAdd(objectStoreName, items, start, count) {
        return new Promise((resolve, reject) => {
            if (start + count > items.length) {
                count = items.length - start;
            }

            if (count <= 0) {
                resolve();
                return;
            }

            const end = start + count;
            let completedCount = 0;
            const onError = (e) => reject(e.target.error);
            const onSuccess = () => {
                if (++completedCount >= count) {
                    resolve();
                }
            };

            const transaction = this.transaction([objectStoreName], 'readwrite');
            const objectStore = transaction.objectStore(objectStoreName);
            for (let i = start; i < end; ++i) {
                const request = objectStore.add(items[i]);
                request.onerror = onError;
                request.onsuccess = onSuccess;
            }
        });
    }

    getAll(objectStoreOrIndex, query, resolve, reject) {
        if (typeof objectStoreOrIndex.getAll === 'function') {
            this._getAllFast(objectStoreOrIndex, query, resolve, reject);
        } else {
            this._getAllUsingCursor(objectStoreOrIndex, query, resolve, reject);
        }
    }

    getAllKeys(objectStoreOrIndex, query, resolve, reject) {
        if (typeof objectStoreOrIndex.getAll === 'function') {
            this._getAllKeysFast(objectStoreOrIndex, query, resolve, reject);
        } else {
            this._getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject);
        }
    }

    find(objectStoreName, indexName, query, predicate=null, defaultValue) {
        return new Promise((resolve, reject) => {
            const transaction = this.transaction([objectStoreName], 'readonly');
            const objectStore = transaction.objectStore(objectStoreName);
            const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore;
            const request = objectStoreOrIndex.openCursor(query, 'next');
            request.onerror = (e) => reject(e.target.error);
            request.onsuccess = (e) => {
                const cursor = e.target.result;
                if (cursor) {
                    const value = cursor.value;
                    if (typeof predicate !== 'function' || predicate(value)) {
                        resolve(value);
                    } else {
                        cursor.continue();
                    }
                } else {
                    resolve(defaultValue);
                }
            };
        });
    }

    bulkCount(targets, resolve, reject) {
        const targetCount = targets.length;
        if (targetCount <= 0) {
            resolve();
            return;
        }

        let completedCount = 0;
        const results = new Array(targetCount).fill(null);

        const onError = (e) => reject(e.target.error);
        const onSuccess = (e, index) => {
            const count = e.target.result;
            results[index] = count;
            if (++completedCount >= targetCount) {
                resolve(results);
            }
        };

        for (let i = 0; i < targetCount; ++i) {
            const index = i;
            const [objectStoreOrIndex, query] = targets[i];
            const request = objectStoreOrIndex.count(query);
            request.onerror = onError;
            request.onsuccess = (e) => onSuccess(e, index);
        }
    }

    delete(objectStoreName, key) {
        return new Promise((resolve, reject) => {
            const transaction = this.transaction([objectStoreName], 'readwrite');
            const objectStore = transaction.objectStore(objectStoreName);
            const request = objectStore.delete(key);
            request.onerror = (e) => reject(e.target.error);
            request.onsuccess = () => resolve();
        });
    }

    bulkDelete(objectStoreName, indexName, query, filterKeys=null, onProgress=null) {
        return new Promise((resolve, reject) => {
            const transaction = this.transaction([objectStoreName], 'readwrite');
            const objectStore = transaction.objectStore(objectStoreName);
            const objectStoreOrIndex = indexName !== null ? objectStore.index(indexName) : objectStore;

            const onGetKeys = (keys) => {
                try {
                    if (typeof filterKeys === 'function') {
                        keys = filterKeys(keys);
                    }
                    this._bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject);
                } catch (e) {
                    reject(e);
                }
            };

            this.getAllKeys(objectStoreOrIndex, query, onGetKeys, reject);
        });
    }

    static deleteDatabase(databaseName) {
        return new Promise((resolve, reject) => {
            const request = indexedDB.deleteDatabase(databaseName);
            request.onerror = (e) => reject(e.target.error);
            request.onsuccess = () => resolve();
            request.onblocked = () => reject(new Error('Database deletion blocked'));
        });
    }

    // Private

    _open(name, version, onUpgradeNeeded) {
        return new Promise((resolve, reject) => {
            const request = indexedDB.open(name, version);

            request.onupgradeneeded = (event) => {
                try {
                    request.transaction.onerror = (e) => reject(e.target.error);
                    onUpgradeNeeded(request.result, request.transaction, event.oldVersion, event.newVersion);
                } catch (e) {
                    reject(e);
                }
            };

            request.onerror = (e) => reject(e.target.error);
            request.onsuccess = () => resolve(request.result);
        });
    }

    _upgrade(db, transaction, oldVersion, upgrades) {
        for (const {version, stores} of upgrades) {
            if (oldVersion >= version) { continue; }

            for (const [objectStoreName, {primaryKey, indices}] of Object.entries(stores)) {
                const existingObjectStoreNames = transaction.objectStoreNames || db.objectStoreNames;
                const objectStore = (
                    this._listContains(existingObjectStoreNames, objectStoreName) ?
                    transaction.objectStore(objectStoreName) :
                    db.createObjectStore(objectStoreName, primaryKey)
                );
                const existingIndexNames = objectStore.indexNames;

                for (const indexName of indices) {
                    if (this._listContains(existingIndexNames, indexName)) { continue; }

                    objectStore.createIndex(indexName, indexName, {});
                }
            }
        }
    }

    _listContains(list, value) {
        for (let i = 0, ii = list.length; i < ii; ++i) {
            if (list[i] === value) { return true; }
        }
        return false;
    }

    _getAllFast(objectStoreOrIndex, query, resolve, reject) {
        const request = objectStoreOrIndex.getAll(query);
        request.onerror = (e) => reject(e.target.error);
        request.onsuccess = (e) => resolve(e.target.result);
    }

    _getAllUsingCursor(objectStoreOrIndex, query, resolve, reject) {
        const results = [];
        const request = objectStoreOrIndex.openCursor(query, 'next');
        request.onerror = (e) => reject(e.target.error);
        request.onsuccess = (e) => {
            const cursor = e.target.result;
            if (cursor) {
                results.push(cursor.value);
                cursor.continue();
            } else {
                resolve(results);
            }
        };
    }

    _getAllKeysFast(objectStoreOrIndex, query, resolve, reject) {
        const request = objectStoreOrIndex.getAllKeys(query);
        request.onerror = (e) => reject(e.target.error);
        request.onsuccess = (e) => resolve(e.target.result);
    }

    _getAllKeysUsingCursor(objectStoreOrIndex, query, resolve, reject) {
        const results = [];
        const request = objectStoreOrIndex.openKeyCursor(query, 'next');
        request.onerror = (e) => reject(e.target.error);
        request.onsuccess = (e) => {
            const cursor = e.target.result;
            if (cursor) {
                results.push(cursor.primaryKey);
                cursor.continue();
            } else {
                resolve(results);
            }
        };
    }

    _bulkDeleteInternal(objectStore, keys, onProgress, resolve, reject) {
        const count = keys.length;
        if (count === 0) {
            resolve();
            return;
        }

        let completedCount = 0;
        const hasProgress = (typeof onProgress === 'function');

        const onError = (e) => reject(e.target.error);
        const onSuccess = () => {
            ++completedCount;
            if (hasProgress) {
                try {
                    onProgress(completedCount, count);
                } catch (e) {
                    // NOP
                }
            }
            if (completedCount >= count) {
                resolve();
            }
        };

        for (const key of keys) {
            const request = objectStore.delete(key);
            request.onerror = onError;
            request.onsuccess = onSuccess;
        }
    }
}