/************************************************************************************
 * (c) 2015-2019 Master Technology
 * Licensed under the MIT license or contact me for a support, changes, enhancements,
 * and/or if you require a commercial licensing
 *
 * Any questions please feel free to email me or put a issue up on github
 * Nathan@master-technology.com                           http://nativescript.tools
 * Version 2.3.1 - iOS
 ***********************************************************************************/
/* global global, require, module */

"use strict";
const fs = require('file-system');

/* jshint undef: true, camelcase: false */
/* global Promise, NSFileManager, NSBundle, NSString, interop, sqlite3_open_v2, sqlite3_close, sqlite3_prepare_v2, sqlite3_step,
 sqlite3_finalize, sqlite3_bind_null, sqlite3_bind_text, sqlite3_column_type, sqlite3_column_int64,
 sqlite3_column_double, sqlite3_column_text, sqlite3_column_count, sqlite3_column_name, sqlitehelper */

let _DatabasePluginInits = [];
const TRANSIENT = sqlitehelper.getTrans();

/***
 * Converts a string to a UTF-8 char array and wraps it in an adopted pointer
 * (which is GC managed and kept alive until it's referenced by a variable).
 * The reason for doing it is that iOS runtime marshals JS string arguments via
 * temporary buffers which are deallocated immediately after the call returns. In
 * some cases however, we need them to stay alive for subsequent native calls
 * because otherwise attempts to read the freed memory may lead to unpredictable
 * app crashes.
 * E.g. `sqlite3_step` function happens to use the stored `char *` passed as
 * `dbname` to `sqlite3_open_v2`.
 * @param str
 * @returns {AdoptedPointer} object
 */
function toCharPtr(str) {
    const objcStr = NSString.stringWithString(str);
    const bufferSize = strlen(objcStr.UTF8String) + 1;
    const buffer = interop.alloc(bufferSize);

    objcStr.getCStringMaxLengthEncoding(buffer, bufferSize, NSUTF8StringEncoding);

    return buffer;
}

/***
 * Creates a Cursor Tracking Statement for reading result sets
 * @param statement
 * @param resultType
 * @param valuesType
 * @constructor
 */
function CursorStatement(statement, resultType, valuesType) {
    this.statement = statement;
    this.resultType = resultType;
    this.valuesType = valuesType;
    this.built = false;
    this.columns = [];
}

//noinspection JSValidateJSDoc
/***
 * Database Constructor
 * @param dbname - Database Name
 * @param options - options
 * @param callback - Callback when Done
 * @returns {Promise} object
 * @constructor
 */
function Database(dbname, options, callback) {
    if (!this instanceof Database) { // jshint ignore:line
        //noinspection JSValidateTypes
        return new Database(dbname, options, callback);
    }
    this._isOpen = false;
    this._resultType = Database.RESULTSASARRAY;
    this._valuesType = Database.VALUESARENATIVE;

    if (typeof options === 'function') {
        callback = options;
        options = {};
    } else {
        options = options || {};
    }

    if (options && options.multithreading && typeof global.Worker === 'function') {
        delete options.multithreading;
        if (!Database.HAS_COMMERCIAL) {
            throw new Error("Commercial only feature; see http://nativescript.tools/product/10");
        }
        return new Database._multiSQL(dbname, options, callback);
    }

    // Check to see if it has a path, or if it is a relative dbname
    // DBNAME = "" - is a Temporary Database
    // DBNAME = ":memory:" - is a Memory only database
    if (dbname !== "" && dbname !== ":memory:") {
        let path;
        if (dbname.indexOf('/') === -1) {
            // noinspection JSUnresolvedVariable, JSUnresolvedFunction
            path = fs.knownFolders.documents().path;
            dbname = path + '/' + dbname;
        } else {
            path = dbname.substr(0, dbname.lastIndexOf('/') + 1);
        }

        // Create "databases" folder if it is missing.  This causes issues on Emulators if it is missing
        // So we create it if it is missing

        try {
            // noinspection JSUnresolvedVariable
            if (!fs.File.exists(path)) {
                //noinspection JSUnresolvedFunction
                const fileManager = iosProperty(NSFileManager, NSFileManager.defaultManager);
                //noinspection JSUnresolvedFunction
                if (!fileManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(path, true, null, null)) {
                    console.warn("SQLITE.CONSTRUCTOR - Creating DB Folder Error");
                }
            }
        }
        catch (err) {
            console.warn("SQLITE.CONSTRUCTOR - Creating DB Folder Error", err);
        }
    }
    this._dbnamePtr = toCharPtr(dbname);
    const self = this;
    //noinspection JSUnresolvedFunction
    return new Promise(function (resolve, reject) {
        let error;
        try {
        	let flags = 0;
        	if (typeof options.iosFlags !== 'undefined') {
        		flags = options.iosFlags;
			}

            self._db = new interop.Reference();
            if (options && options.readOnly) {
                // SQLITE_OPEN_FULLMUTEX = 65536, SQLITE_OPEN_READONLY = 1 ---- 1 | 65536 = 65537
                error = sqlite3_open_v2(self._dbnamePtr, self._db, 65537 | flags, null);
            } else {
                // SQLITE_OPEN_FULLMUTEX = 65536, SQLITE_OPEN_CREATE = 4, SQLITE_OPEN_READWRITE = 2 --- 4 | 2 | 65536 = 65542
                error = sqlite3_open_v2(self._dbnamePtr, self._db, 65542 | flags, null);
            }
            self._db = self._db.value;
        } catch (err) {
            if (callback) { callback(err, null); }
            reject(err);
            return;
        }
        if (error) {
            if (callback) { callback(error, null); }
            reject(error);
            return;
        }

        self._isOpen = true;

        let doneCnt = _DatabasePluginInits.length, doneHandled = 0;
        const done = function(err) {
        	if (err) {
        		doneHandled = doneCnt;  // We don't want any more triggers after this
				if (callback) { callback(err, null); }
				reject(err);
        		return;
			}
			doneHandled++;
        	if (doneHandled === doneCnt) {
				if (callback) { callback(null, self); }
				resolve(self);
			}
		};

        if (doneCnt) {
        	try {
				for (let i = 0; i < doneCnt; i++) {
					_DatabasePluginInits[i].call(self, options, done);
				}
			} catch (err) {
        		done(err);
			}
		} else {
			if (callback) { callback(null, self); }
			resolve(self);
		}

    });
}

/***
 * Constant that this structure is a sqlite structure
 * @type {boolean}
 */
Database.prototype._isSqlite = true;

/***
 * This gets or sets the database version
 * @param valueOrCallback to set or callback(err, version)
 * @returns Promise
 */
Database.prototype.version = function(valueOrCallback) {
    if (typeof valueOrCallback === 'function') {
        return this.get('PRAGMA user_version', function (err, data) {
            valueOrCallback(err, data && parseInt(data[0],10));
        }, Database.RESULTSASARRAY);
    } else if (!isNaN(valueOrCallback+0)) {
        return this.execSQL('PRAGMA user_version='+(valueOrCallback+0).toString());
    } else {
        return this.get('PRAGMA user_version', undefined, undefined, Database.RESULTSASARRAY);
    }
};

/***
 * Is the database currently open
 * @returns {boolean} - true if the db is open
 */
Database.prototype.isOpen = function() {
    return this._isOpen;
};

/***
 * Gets/Sets whether you get Arrays or Objects for the row values
 * @param value - Database.RESULTSASARRAY or Database.RESULTSASOBJECT
 * @returns {number} - Database.RESULTSASARRAY or Database.RESULTSASOBJECT
 */
Database.prototype.resultType = function(value) {
    if (value === Database.RESULTSASARRAY) {
        this._resultType = Database.RESULTSASARRAY;
    } else if (value === Database.RESULTSASOBJECT) {
        this._resultType = Database.RESULTSASOBJECT;
    }
    return this._resultType;
};

/***
 * Gets/Sets whether you get Native or Strings for the row values
 * @param value - Database.VALUESARENATIVE or Database.VALUESARESTRINGS
 * @returns {number} - Database.VALUESARENATIVE or Database.VALUESARESTRINGS
 */
Database.prototype.valueType = function(value) {
    if (value === Database.VALUESARENATIVE) {
        this._valuesType = Database.VALUESARENATIVE;
    } else if (value === Database.VALUESARESTRINGS) {
        this._valuesType = Database.VALUESARESTRINGS;
    }
    return this._valuesType;
};

/**
 * Dummy transaction function for public version
 * @param callback
 * @returns {Promise<T>}
 */
Database.prototype.begin = function(callback) {
    throw new Error("Transactions are a Commercial version feature.");
};

/**
 * Dummy prepare function for public version
 * @param sql
 * @returns {*}
 */
Database.prototype.prepare = function(sql) {
	throw new Error("Prepared statements are a Commercial version feature.");
};

/***
 * Closes this database, any queries after this will fail with an error
 * @param callback
 * @returns Promise
 */
Database.prototype.close = function(callback) {

    const self = this;
    return new Promise( function (resolve, reject) {
        if (!self._isOpen) {
            if (callback) {
                callback('SQLITE.CLOSE - Database is already closed');
            }
            reject('SQLITE.CLOSE - Database is already closed');
            return;

        }

        sqlite3_close(self._db);
        self._db = null;
        self._isOpen = false;
        if (callback) {
            callback(null, null);
        }
        resolve();
    });
};

/***
 * Exec SQL
 * @param sql - sql to use
 * @param params - optional array of parameters
 * @param callback - (err, result) - can be last_row_id for insert, and rows affected for update/delete
 * @returns Promise
 */
Database.prototype.execSQL = function(sql, params, callback) {
    if (typeof params === 'function') {
        callback = params;
        params = undefined;
    }

    const self = this;
    return new Promise( function(resolve, reject) {

        let hasCallback = true;
        if (typeof callback !== 'function') {
            callback = reject;
            hasCallback = false;
        }

        if (!self._isOpen) {
            callback("SQLITE.EXECSQL - Database is not open");
            return;
        }

        // Need to see if we have to run any status queries afterwords
        let flags = 0;
        let test = sql.trim().substr(0, 7).toLowerCase();
        if (test === 'insert ') {
            flags = 1;
        } else if (test === 'update ' || test === 'delete ') {
            flags = 2;
        }

        let res;
        try {
            let statement = new interop.Reference();
            res = sqlite3_prepare_v2(self._db, sql, -1, statement, null);
            statement = statement.value;
            if (res) {
                callback("SQLITE.ExecSQL Failed Prepare: " + res);
                return;
            }
            if (params !== undefined) {
                if (!self._bind(statement, params)) {
                    sqlite3_finalize(statement);
                    callback("SQLITE.ExecSQL Bind Error");
                    return;
                }
            }
            let result = sqlite3_step(statement);
            sqlite3_finalize(statement);
            if (result && result !== 100 && result !== 101) {
                callback("SQLITE.ExecSQL Failed " + result);
                return;
            }

        } catch (Err) {
            callback(Err, null);
            return;
        }

        switch (flags) {
            case 0:
                if (hasCallback) {
                    callback();
                }
                resolve();
                break;

            case 1:
                self.get('select last_insert_rowid()', function (err, data) {
                    if (hasCallback) {
                        callback(err, data && data[0]);
                    }
                    if (err) {
                        reject(err);
                    } else {
                        resolve(data && data[0]);
                    }
                }, Database.RESULTSASARRAY | Database.VALUESARENATIVE);
                break;

            case 2:
                self.get('select changes()', function (err, data) {
                    if (hasCallback) {
                        callback(err, data && data[0]);
                    }
                    if (err) {
                        reject(err);
                    } else {
                        resolve(data && data[0]);
                    }
                }, Database.RESULTSASARRAY | Database.VALUESARENATIVE);
                break;

            default:
                if (hasCallback) {
                    callback();
                }
                resolve();
        }
    });
};

/***
 * Get the first record result set
 * @param sql - sql to run
 * @param params - optional
 * @param callback - callback (error, results)
 * @param mode - allows you to manually override the results set to be a array or object
 * @returns Promise
 */
Database.prototype.get = function(sql, params, callback, mode) {
    if (typeof params === 'function') {
        mode = callback;
        callback = params;
        params = undefined;
    }

    const self = this;
    return new Promise( function (resolve, reject) {
        let hasCallback = true;

        if (typeof callback !== 'function') {
            callback = reject;
            hasCallback = false;
        }

        if (!self._isOpen) {
            callback("SQLITE.GET - Database is not open");
            return;
        }

        let cursor;
        try {
            let statement = new interop.Reference();
            let res = sqlite3_prepare_v2(self._db, sql, -1, statement, null);
            if (res) {
                callback("SQLITE.GET Failed Prepare: " + res);
                return;
            }
            statement = statement.value;
            let cursorStatement = new CursorStatement(statement, self._resultType, self._valuesType);

            if (params !== undefined) {
                if (!self._bind(statement, params)) {
                    sqlite3_finalize(statement);
                    callback("SQLITE.GET Bind Error");
                    return;
                }
            }

            let result = sqlite3_step(statement);
            if (result === 100) {
                cursor = self._getResults(cursorStatement, mode);
            }
            sqlite3_finalize(statement);
            if (result && result !== 100 && result !== 101) {
                callback("SQLITE.GET - Step Error" + result);
                return;
            }
        } catch (err) {
            callback(err);
            return;
        }

        // No Records
        if (!cursor) {
            if (hasCallback) {
                callback(null, null);
            }
            resolve(null);
            return;
        }

        if (hasCallback) {
            callback(null, cursor);
        }
        resolve(cursor);
    });
};

/***
 * This returns the entire result set in a array of rows
 * @param sql - Sql to run
 * @param params - optional
 * @param callback - (err, results)
 * @returns Promise
 */
Database.prototype.all = function(sql, params, callback) {
    if (typeof params === 'function') {
        callback = params;
        params = undefined;
    }

    const self = this;
    return new Promise(function(resolve, reject) {

        let hasCallback = true;
        if (typeof callback !== 'function') {
            callback = reject;
            hasCallback = false;
        }

        if (!self._isOpen) {
            callback("SQLITE.ALL - Database is not open");
            return;
        }

        let rows = [], res;
        try {
            let statement = new interop.Reference();
            res = sqlite3_prepare_v2(self._db, sql, -1, statement, null);
            if (res) {
                callback("SQLITE.ALL - Prepare Error " + res);
                return;
            }

            statement = statement.value;
            let cursorStatement = new CursorStatement(statement, self._resultType, self._valuesType);

            if (params !== undefined) {
                if (!self._bind(statement, params)) {
                    sqlite3_finalize(statement);
                    callback("SQLITE.ALL Bind Error");
                    return;
                }
            }

            let result;
            do {
                result = sqlite3_step(statement);
                if (result === 100) {
                    let cursor = self._getResults(cursorStatement);
                    if (cursor) {
                        rows.push(cursor);
                    }
                } else if (result && result !== 101) {
                    sqlite3_finalize(statement);
                    callback("SQLITE.ALL - Database Error" + result);
                    return;
                }
            } while (result === 100);
            sqlite3_finalize(statement);
        } catch (err) {
            callback(err, null);
            return;
        }

        // No Records
        if (rows.length === 0) {
            if (hasCallback) {
                callback(null, []);
            }
            resolve([]);
            return;
        }

        if (hasCallback) {
            callback(null, rows);
        }
        resolve(rows);
    });
};

/***
 * This sends each row of the result set to the "Callback" and at the end calls the complete callback upon completion
 * @param sql - sql to run
 * @param params - optional
 * @param callback - callback (err, rowsResult)
 * @param complete - callback (err, recordCount)
 * @returns {Promise}
 */
Database.prototype.each = function(sql, params, callback, complete) {
    if (typeof params === 'function') {
        complete = callback;
        callback = params;
        params = undefined;
    }

    // Callback is required
    if (typeof callback !== 'function') {
        throw new Error("SQLITE.EACH - requires a callback");
    }

    const self = this;
    return new Promise (function(resolve, reject) {

        // Set the error Callback
        let errorCB = complete || callback;

        let count = 0, res;
        try {

            let statement = new interop.Reference();
            res = sqlite3_prepare_v2(self._db, sql, -1, statement, null);
            if (res) {
                errorCB("SQLITE.EACH Error in Prepare" + res);
                reject("SQLITE.EACH Error in Prepare" + res);
                return;
            }
            statement = statement.value;
            let cursorStatement = new CursorStatement(statement, self._resultType, self._valuesType);

            if (params !== undefined) {
                if (!self._bind(statement, params)) {
                    sqlite3_finalize(statement);
                    errorCB("SQLITE.EACH Bind Error");
                    reject("SQLITE.EACH Bind Error");
                    return;
                }
            }
            let result;
            do {
                result = sqlite3_step(statement);
                if (result === 100) {
                    let cursor = self._getResults(cursorStatement);
                    if (cursor) {
                        count++;
                        callback(null, cursor);
                    }
                } else if (result && result !== 101) {
                    sqlite3_finalize(statement);
                    errorCB("SQLITE.EACH - Database Error " + result);
                    reject("SQLITE.EACH - Database Error " + result);
                    return;
                }
            } while (result === 100);
            sqlite3_finalize(statement);
        } catch (err) {
            errorCB(err, null);
            reject(err);
            return;
        }

        if (complete) {
            complete(null, count);
        }
        resolve(count);
    });
};

/**
 * Binds the Parameters in a Statement
 * @param statement
 * @param params
 * @private
 */
Database.prototype._bind = function(statement, params) {
    let param, res;

	if (Array.isArray(params)) {
        const count = params.length;
        for (let i=0; i<count; ++i) {
            if (params[i] == null) { // jshint ignore:line
                res = sqlite3_bind_null(statement, i+1);
            } else {
                param = params[i].toString();
                res = sqlite3_bind_text(statement, i+1, param, -1, TRANSIENT );
            }
            if (res) {
                console.error("SQLITE.Binding Error ", res);
                return false;
            }
        }
    } else {
        if (params == null) { // jshint ignore:line
            res = sqlite3_bind_null(statement, 1);
        } else {
            param = params.toString();
			res = sqlite3_bind_text(statement, 1, param, -1, TRANSIENT );
        }
        if (res) {
            console.error("SQLITE.Binding Error ", res);
            return false;
        }
    }
    return true;
};

Database.prototype._getNativeResult = function(statement, column) {
    const resultType = sqlite3_column_type(statement, column);
    switch (resultType) {
        case 1: // Int
            return sqlite3_column_int64(statement, column);
        case 2: // Float
            return sqlite3_column_double(statement, column);
        case 3: // Text
            //noinspection JSUnresolvedFunction
            return NSString.stringWithUTF8String(sqlite3_column_text(statement, column)).toString();
        case 4: // Blob
            return NSData.dataWithBytesLength(sqlite3_column_blob(statement, column), sqlite3_column_bytes(statement, column));
        case 5: // Null
            return null;
        default:
            //noinspection JSUnresolvedFunction
            return NSString.stringWithUTF8String(sqlite3_column_text(statement, column)).toString();
    }
};

Database.prototype._getStringResult = function(statement, column) {
    const resultType = sqlite3_column_type(statement, column);
    switch (resultType) {
        case 1: // Int
            //return sqlite3_column_int(statement, column).toString();
            return NSString.stringWithUTF8String(sqlite3_column_text(statement, column)).toString();
        case 2: // Float
            //return sqlite3_column_double(statement, column).toString();
            return NSString.stringWithUTF8String(sqlite3_column_text(statement, column)).toString();
        case 3: // Text
            //noinspection JSUnresolvedFunction
            return NSString.stringWithUTF8String(sqlite3_column_text(statement, column)).toString();
        case 4: // Blob
            return NSData.dataWithBytesLength(sqlite3_column_blob(statement, column), sqlite3_column_bytes(statement, column));
        case 5: // Null
            return null;
        default:
            //noinspection JSUnresolvedFunction
            return NSString.stringWithUTF8String(sqlite3_column_text(statement, column)).toString();
    }
};

Database.prototype._getResults = function(cursorStatement, mode) {
    let resultType, valueType;
    let statement = cursorStatement.statement;
    let i;

    if (!mode) {
        resultType = cursorStatement.resultType;
        valueType = cursorStatement.valuesType;
    } else {
        resultType = (mode & (Database.RESULTSASARRAY | Database.RESULTSASOBJECT));
        valueType = (mode & (Database.VALUESARENATIVE | Database.VALUESARESTRINGS));
        if (resultType <= 0) {
            resultType = cursorStatement.resultType;
        }
        if (valueType <= 0) {
            valueType = cursorStatement.valuesType;
        }
    }

    // Track this statements information so we don't have to build it each time
    if (!cursorStatement.built) {
        cursorStatement.count = sqlite3_column_count(statement);
        if (resultType === Database.RESULTSASOBJECT) {
            for (i=0;i<cursorStatement.count;i++) {
                //noinspection JSUnresolvedFunction
                let cn =  NSString.stringWithUTF8String(sqlite3_column_name(statement, i)).toString();
                if (!cn || cursorStatement.columns.indexOf(cn) >= 0) {
                    cn = "column"+i;
                }
                cursorStatement.columns.push(cn);
            }
        }
        cursorStatement.built=true;
    }

    let cnt = cursorStatement.count, data;
    if (cnt === 0) { return null; }
    if (resultType === Database.RESULTSASARRAY) {
        data = [];
        if (valueType === Database.VALUESARESTRINGS) {
            for (i = 0; i < cnt; i++) {
                data.push(this._getStringResult(statement, i));
            }
        } else {
            for (i = 0; i < cnt; i++) {
                data.push(this._getNativeResult(statement, i));
            }
        }
        return data;
    } else {
        let colName = cursorStatement.columns;
        data = {};
        if (valueType === Database.VALUESARESTRINGS) {
            for (i = 0; i < cnt; i++) {
                data[colName[i]] = this._getStringResult(statement, i);
            }
        } else {
            for (i = 0; i < cnt; i++) {
                data[colName[i]] = this._getNativeResult(statement, i);
            }
        }
        return data;
    }
};

/***
 * Is this a SQLite object
 * @param obj - possible sqlite object to check
 * @returns {boolean}
 */
Database.isSqlite = function(obj) {
    return obj && obj._isSqlite;
};

Database.exists = function(name) {
    if (name.indexOf('/') === -1) {
        name = fs.knownFolders.documents().path + '/' + name;
    }

    //noinspection JSUnresolvedFunction
    const fileManager = iosProperty(NSFileManager, NSFileManager.defaultManager);

    return fileManager.fileExistsAtPath(name);
};

Database.deleteDatabase = function(name) {
    //noinspection JSUnresolvedFunction
    const fileManager = iosProperty(NSFileManager, NSFileManager.defaultManager);

    let path;
    if (name.indexOf('/') === -1) {
        path = fs.knownFolders.documents().path + '/';
    } else {
        path = name.substr(0, name.lastIndexOf('/') + 1);
        name = name.substr(path.length);
    }

    if (!fileManager.fileExistsAtPath(path + name)) { return; }

    // Need to remove the trailing .sqlite
    let idx = name.lastIndexOf('.');
    if (idx) {
        name = name.substr(0,idx);
    }

    let files = fileManager.contentsOfDirectoryAtPathError(path, null);
    if (!files) {
        return;
    }

    for (let i = 0; i < files.count; i++) {
        const fileName = files.objectAtIndex(i);
        if (fileName.indexOf(name) !== -1) {
            fileManager.removeItemAtPathError(path + fileName, null);
        }
    }
};

Database.copyDatabase = function(name) {
    //noinspection JSUnresolvedFunction

    const fileManager = iosProperty(NSFileManager, NSFileManager.defaultManager);

    let path;
    if (name.indexOf('/') === -1) {
        path = fs.knownFolders.documents().path + '/';
    } else {
        path = name.substr(0, name.lastIndexOf('/') + 1);
        name = name.substr(path.length);
    }

    let source = fs.knownFolders.currentApp().path + '/' + name;
    let destination = path + name;
    fileManager.copyItemAtPathToPathError(source, destination, null);
};

function UsePlugin(loadedSrc, DBModule) {
		if (loadedSrc.prototypes) {
			for (let key in loadedSrc.prototypes) {
				DBModule.prototype[key] = loadedSrc.prototypes[key];
			}
		}
		if (loadedSrc.statics) {
			for (let key in loadedSrc.statics) {
				DBModule[key] = loadedSrc.statics[key];
			}
		}
		if (typeof loadedSrc.init === 'function') {
			_DatabasePluginInits.push(loadedSrc.init);
		}
}

function iosProperty(_this, property) {
    if (typeof property === "function") {
        // xCode < 8
        return property.call(_this);
    } else {
        // xCode >= 8
        return property;
    }
}

// Literal Defines
Database.RESULTSASARRAY  = 1;
Database.RESULTSASOBJECT = 2;
Database.VALUESARENATIVE = 4;
Database.VALUESARESTRINGS = 8;

/** These are optional plugins, must have a static require statement for webpack **/
TryLoadingCommercialPlugin();
TryLoadingEncryptionPlugin();

function TryLoadingCommercialPlugin() {
	try {
		let sqlCom = require('nativescript-sqlite-commercial');
		UsePlugin(sqlCom, Database);
	}
	catch (e) { /* Do Nothing if it doesn't exist as it is an optional plugin */
	}
}

function TryLoadingEncryptionPlugin() {
	try {
		let sqlEnc = require('nativescript-sqlite-encrypted');
		UsePlugin(sqlEnc, Database);
	}
	catch (e) { /* Do Nothing if it doesn't exist as it is an optional plugin */
	}
}

module.exports = Database;


var Decode = require('jwt-decode');

var assign = require('lodash.assign');
var defaultsDeep = require('lodash.defaultsdeep');

module.exports = CronJob;

/**
 * Creates an object representing a CronJob
 *
 * @constructor
 */
function CronJob (sandbox, job) {
    /**
     * @property name - The name of the cron job
     * @property schedule - The cron schedule of the job
     * @property next_scheduled_at - The next time this job is scheduled
     */
    assign(this, job);

    /**
     * @property claims - The claims embedded in the Webtask's token
     */
    if (job.token) {
        this.claims = Decode(job.token);
    }
    else {
        this.claims = {
            jtn: job.name,
            ten: this.container,
        };
    }

    /**
     * @property sandbox - The {@see Sandbox} instance used to create this Webtask instance
     */
    this.sandbox = sandbox;

    /**
     * @property url - The public url that can be used to invoke webtask that the cron job runs
     */
    Object.defineProperty(this, 'url', {
        enumerable: true,
        get: function () {
            return this.sandbox.url + '/api/run/' + this.container + '/' + this.name;
        }
    });
}

/**
 * Refresh this job's metadata
 *
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the this cron job instance
 */
CronJob.prototype.refresh = function (cb) {
    var promise = this.sandbox.getCronJob({
        container: this.container,
        name: this.name,
        handleMetadata: handleMetadata.bind(this),
    });

    return cb ? promise.nodeify(cb) : promise;

    function handleMetadata(job) {
        assign(this, job);

        this.claims = Decode(job.token);

        return this;
    }
};

/**
 * Remove this cron job from the webtask cluster
 *
 * Note that this will not revoke the underlying webtask token, so the underlying webtask will remain functional.
 *
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
CronJob.prototype.remove = function (cb) {
    var promise = this.sandbox.removeCronJob({
        container: this.container,
        name: this.name,
    });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Get the history of this cron job
 *
 * @param {Object} options - Options for retrieving the cron job.
 * @param {String} [options.offset] - The offset to use when paging through results.
 * @param {String} [options.limit] - The limit to use when paging through results.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with an Array of cron job results.
 */
CronJob.prototype.getHistory = function (options, cb) {
    if (typeof options === 'function') {
        cb = options;
        options = {};
    }

    if (!options) options = {};

    options = defaultsDeep(options, {
        container: this.container,
        name: this.name,
        offset: 0,
        limit: 10,
    });

    var promise = this.sandbox.getCronJobHistory(options);

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Inspect an existing webtask to optionally get code and/or secrets
 *
 * @param {Object} options - Options for inspecting the webtask.
 * @param {Boolean} [options.fetch_code] - Fetch the code associated with the webtask.
 * @param {Boolean} [options.decrypt] - Decrypt the webtask's secrets.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with an Array of cron job results.
 */
CronJob.prototype.inspect = function (options, cb) {
    options.token = this.token;

    var promise = this.sandbox.inspectToken(options);

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Set the cron job's state
 *
 * @param {Object} options - Options for updating the webtask.
 * @param {Boolean} options.state - Set the cron job's state to this.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with an Array of cron job results.
 */
CronJob.prototype.setJobState = function (options, cb) {
    options.token = this.token;

    var self = this;
    var promise = this.sandbox.setCronJobState({
        container: this.container,
        name: this.name,
        state: options.state,
    })
        .tap(function (job) {
            assign(self, job);
        });

    return cb ? promise.nodeify(cb) : promise;
};


var Bluebird = require('bluebird');
var CronJob = require('./cronJob');
var Decode = require('jwt-decode');
var LogStream = require('webtask-log-stream');
var RandExp = require('randexp');
var Request = require('./issueRequest');
var Superagent = require('superagent');
var Webtask = require('./webtask');

var defaults = require('lodash.defaults');

/**
Sandbox node.js code.
@module sandboxjs
@typicalname Sandbox
*/
module.exports = Sandbox;
module.exports.PARSE_NEVER = 0;
module.exports.PARSE_ALWAYS = 1;
module.exports.PARSE_ON_ARITY = 2;

/**
 * Creates an object representing a user's webtask.io credentials
 *
 * @constructor
 * @param {Object} options - Options used to configure the profile
 * @param {String} options.url - The url of the webtask cluster where code will run
 * @param {String} options.container - The name of the container in which code will run
 * @param {String} options.token - The JWT (see: http://jwt.io) issued by webtask.io that grants rights to run code in the indicated container
 * @param {String} [options.onBeforeRequest] - An array of hook functions to be invoked with a prepared request
 */
function Sandbox (options) {
    var securityVersion = 'v1';

    this.url = options.url;
    this.container = options.container;
    this.token = options.token;
    this.onBeforeRequest = []
        .concat(options.onBeforeRequest)
        .filter(hook => typeof hook === 'function');

    try {
        var typ = Decode(options.token, { header: true }).typ;

        if (typ && typ.toLowerCase() === 'jwt') {
            securityVersion = 'v2';
        }
    } catch (_) {
        // Ignore jwt decoding failures and assume v1 opaque token
    }

    this.securityVersion = securityVersion;
}

/**
 * Create a clone of this sandbox instances with one or more different parameters
 *
 * @param {Object} options - Options used to configure the profile
 * @param {String} [options.url] - The url of the webtask cluster where code will run
 * @param {String} [options.container] - The name of the container in which code will run
 * @param {String} [options.token] - The JWT (see: http://jwt.io) issued by webtask.io that grants rights to run code in the indicated container
 * @param {String} [options.onBeforeRequest] - An array of hook functions to be invoked with a prepared request
 */
Sandbox.prototype.clone = function (options) {
    return new Sandbox({
        url: options.url || this.url,
        container: options.container || this.container,
        token: options.token || this.token,
        onBeforeRequest: options.onBeforeRequest || this.onBeforeRequest,
    });
};

/**
 * Create a Webtask from the given options
 *
 * @param {String} [codeOrUrl] - The code for the webtask or a url starting with http:// or https://
 * @param {Object} [options] - Options for creating the webtask
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.create = function (codeOrUrl, options, cb) {
    if (typeof codeOrUrl !== 'string') {
        cb = options;
        options = codeOrUrl;
        codeOrUrl = typeof options.code === 'string'
            ? options.code
            : options.code_url;
    }

    if (typeof options === 'function') {
        cb = options;
        options = {};
    }

    if (!options) options = {};

    var fol = codeOrUrl.toLowerCase();

    if (fol.indexOf('http://') === 0 || fol.indexOf('https://') === 0) {
        options.code_url = codeOrUrl;
    } else {
        options.code = codeOrUrl;
    }

    var self = this;
    var token_options = defaults({}, options, { include_webtask_url: true });
    var promise = this.createToken(token_options)
        .then(function (result) {
            return token_options.include_webtask_url
                ? new Webtask(self, result.token, { meta: options.meta, webtask_url: result.webtask_url })
                : new Webtask(self, result, { meta: options.meta });
        });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Create a Webtask from the given claims
 *
 * @param {Object} claims - Options for creating the webtask
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.createRaw = function (claims, cb) {
    var self = this;

    var promise = this.createTokenRaw(claims)
        .then(function (token) {
            return new Webtask(self, token, { meta: claims.meta });
        });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Shortcut to create a Webtask and get its url from the given options
 *
 * @param {Object} options - Options for creating the webtask
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.createUrl = function (options, cb) {
    var promise = this.create(options)
        .get('url');

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Shortcut to create and run a Webtask from the given options
 *
 * @param {String} [codeOrUrl] - The code for the webtask or a url starting with http:// or https://
 * @param {Object} [options] - Options for creating the webtask
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.run = function (codeOrUrl, options, cb) {
    if (typeof options === 'function') {
        cb = options;
        options = {};
    }

    if (!options) options = {};

    var promise = this.create(codeOrUrl, options)
        .call('run', options);

    return cb ? promise.nodeify(cb, {spread: true}) : promise;
};

/**
 * Create a webtask token - A JWT (see: http://jwt.io) with the supplied options
 *
 * @param {Object} options - Claims to make for this token (see: https://webtask.io/docs/api_issue)
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.createToken = function (options, cb) {
    if (!options) options = {};

    var self = this;
    var promise = new Bluebird(function (resolve, reject) {
        var params = {
            ten: options.container || self.container,
            dd: options.issuanceDepth || 0,
        };

        if (options.exp !== undefined && options.nbf !== undefined
            && options.exp <= options.nbf) {
            return reject('The `nbf` parameter cannot be set to a later time than `exp`.');
        }

        if (options.host)
            params.host = options.host;
        if (options.code_url)
            params.url = options.code_url;
        if (typeof options.code === 'string')
            params.code = options.code;
        if (options.secrets && Object.keys(options.secrets).length > 0)
            params.ectx = options.secrets;
        if (options.secret && Object.keys(options.secret).length > 0)
            params.ectx = options.secret;
        if (options.params && Object.keys(options.params).length > 0)
            params.pctx = options.params;
        if (options.param && Object.keys(options.param).length > 0)
            params.pctx = options.param;
        if (options.meta && Object.keys(options.meta).length > 0)
            params.meta = options.meta;
        if (options.nbf !== undefined)
            params.nbf = options.nbf;
        if (options.exp !== undefined)
            params.exp = options.exp;
        if (options.merge || options.mergeBody)
            params.mb = 1;
        if ((options.parse || options.parseBody) !== undefined)
            // This can be a numeric value from the PARSE_* enumeration
            // or a boolean that will be normalized to 0 or 1.
            params.pb = +(options.parse || options.parseBody);
        if (!options.selfRevoke)
            params.dr = 1;
        if (options.name)
            params.jtn = options.name;

        try {
            if (options.tokenLimit)
                addLimits(options.tokenLimit, Sandbox.limits.token);
            if (options.containerLimit)
                addLimits(options.containerLimit, Sandbox.limits.container);
        } catch (err) {
            return reject(err);
        }

        return resolve(self.createTokenRaw(params, { include_webtask_url: options.include_webtask_url }));

        function addLimits(limits, spec) {
            for (var l in limits) {
                var limit = parseInt(limits[l], 10);

                if (!spec[l]) {
                    throw new Error('Unsupported limit type `' + l
                        + '`. Supported limits are: '
                        + Object.keys(spec).join(', ') + '.');
                }

                if (isNaN(limits[l]) || Math.floor(+limits[l]) !== limit
                    || limit < 1) {
                        throw new Error('Unsupported limit value for `' + l
                            + '` limit. All limits must be positive integers.');
                }

                params[spec[l]] = limit;
            }
        }
    });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Run a prepared Superagent request through any configured
 * onBeforeRequest hooks.
 *
 * This can be useful for enablying proxies for server-side
 * consumers of sandboxjs.
 *
 * @param {Superagent.Request} request - Instance of a superagent request
 * @param {function} [cb] - Node-style callback function
 * @returns {Promise} - A promise representing the fulfillment of the request
 */
Sandbox.prototype.issueRequest = function (request, cb) {
    const transformedRequest = this.onBeforeRequest
        .reduce((request, hook) => {
            return hook(request, this);
        }, request);
    const promise = Request(transformedRequest);

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Create a webtask token - A JWT (see: http://jwt.io) with the supplied claims
 *
 * @param {Object} claims - Claims to make for this token (see: https://webtask.io/docs/api_issue)
 * @param {Object} [options] - Optional options. Currently only options.include_webtask_url is supported.
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.createTokenRaw = function (claims, options, cb) {
    if (typeof options === 'function') {
        cb = options;
        options = undefined;
    }
    var request = Superagent
        .post(this.url + '/api/tokens/issue')
        .set('Authorization', 'Bearer ' + this.token)
        .send(claims);

    var promise = this.issueRequest(request)
            .then(function (res) {
                return (options && options.include_webtask_url)
                    ? { token: res.text, webtask_url: res.header['location'] }
                    : res.text;
            });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Create a stream of logs from the webtask container
 *
 * Note that the logs will include messages from our infrastructure.
 *
 * @param {Object} options - Streaming options overrides
 * @param {String} [options.container] - The container for which you would like to stream logs. Defaults to the current profile's container.
 * @returns {Stream} A stream that will emit 'data' events with container logs
 */
Sandbox.prototype.createLogStream = function (options) {
    if (!options) options = {};

    var url = this.url + '/api/logs/tenant/'
        + (options.container || this.container)
        + '?key=' + encodeURIComponent(this.token);

    return LogStream(url);
};

Sandbox.prototype._createCronJob = function (job) {
    return new CronJob(this, job);
};

/**
 * Read a named webtask
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the webtask.
 * @param {Boolean} [options.decrypt] - Decrypt the webtask's secrets.
 * @param {Boolean} [options.fetch_code] - Fetch the code associated with the webtask.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Sandbox.prototype.getWebtask = function (options, cb) {
    if (!options) options = {};

    var promise;

    if (!options.name) {
        var err = new Error('Missing required option: `options.name`');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    } else {
        var url = this.url + '/api/webtask/'
            + (options.container || this.container) + '/' + options.name;
        var request = Superagent
            .get(url)
            .set('Authorization', 'Bearer ' + this.token)
            .accept('json');
        var self = this;

        if (options.decrypt) request.query({ decrypt: options.decrypt });
        if (options.fetch_code) request.query({ fetch_code: options.fetch_code });

        promise = this.issueRequest(request)
            .get('body')
            .then(function (data) {
                return new Webtask(self, data.token, { name: data.name, secrets: data.secrets, code: data.code, meta: data.meta, webtask_url: data.webtask_url });
            });
    }

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Create a named webtask
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the webtask.
 * @param {String} [options.secrets] - Set the webtask secrets.
 * @param {String} [options.meta] - Set the webtask metadata.
 * @param {String} [options.host] - Set the webtask hostname.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Sandbox.prototype.createWebtask = function (options, cb) {
    if (!options) options = {};

    var promise;
    var err;

    if (typeof options.name !== 'string') {
        err = new Error('The `name` option is required and must be a string');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (options.code && typeof options.code !== 'string') {
        err = new Error('The `code` option must be a string');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (options.url && typeof options.url !== 'string') {
        err = new Error('The `url` option must be a string');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (options.code && options.url) {
        err = new Error('Either the `code` or `url` option can be specified, but not both');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (!err) {
        var url = this.url + '/api/webtask/'
            + (options.container || this.container) + '/' + options.name;
        var payload = {};
        if (options.secrets) payload.secrets = options.secrets;
        if (options.code) payload.code = options.code;
        if (options.url) payload.url = options.url;
        if (options.meta) payload.meta = options.meta;
        if (options.host) payload.host = options.host;
        var request = Superagent
            .put(url)
            .set('Authorization', 'Bearer ' + this.token)
            .send(payload)

        var self = this;
        promise = this.issueRequest(request)
            .then(function (res) {
                return new Webtask(self, res.body.token, { name: res.body.name, container: options.container || self.container, meta: res.body.meta, webtask_url: res.body.webtask_url });
            });
    }

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Remove a named webtask from the webtask container
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the cron job.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Sandbox.prototype.removeWebtask = function (options, cb) {
    if (!options) options = {};

    var promise;

    if (!options.name) {
        var err = new Error('Missing required option: `options.name`');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    } else {
        var url = this.url + '/api/webtask/'
            + (options.container || this.container) + '/' + options.name;
        var request = Superagent
            .del(url)
            .set('Authorization', 'Bearer ' + this.token);

        promise = this.issueRequest(request)
            .return(true);
    }

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Update an existing webtask's code, secrets or other claims
 *
 * Note that this method should be used with caution as there is the potential
 * for a race condition where another agent updates the webtask between the time
 * that the webtask details and claims are resolved and when the webtask
 * update is issued.
 *
 * @param {Object} options - Options
 * @param {String} options.name - Name of the webtask to update
 * @param {String} [options.code] - Updated code for the webtask
 * @param {String} [options.url] - Updated code URL for the webtask
 * @param {String} [options.secrets] - If `false`, remove existing secrets, if an object update secrets, otherwise preserve
 * @param {String} [options.params] - If `false`, remove existing params, if an object update params, otherwise preserve
 * @param {String} [options.host] - If `false`, remove existing host, if a string update host, otherwise preserve
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an instance of Webtask representing the updated webtask
 */
Sandbox.prototype.updateWebtask = function (options, cb) {
    if (!options) options = {};

    var self = this;
    var err;
    var promise;

    if (typeof options.name !== 'string') {
        err = new Error('The `name` option is required and must be a string');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (options.code && typeof options.code !== 'string') {
        err = new Error('The `code` option must be a string');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (options.url && typeof options.url !== 'string') {
        err = new Error('The `url` option must be a string');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (options.code && options.url) {
        err = new Error('Either the `code` or `url` option can be specified, but not both');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }

    if (!err) {
        promise = this.getWebtask({ name: options.name })
            .then(onWebtask);

    }

    return cb ? promise.nodeify(cb) : promise;

    function onWebtask(webtask) {
        return webtask.inspect({ decrypt: options.secrets !== false, fetch_code: options.code !== false, meta: options.meta !== false })
            .then(onInspection);

        function onInspection(claims) {
            var newClaims = {
                ten: claims.ten,
                jtn: claims.jtn,
            };

            ['nbf', 'exp', 'dd', 'dr', 'ls', 'lm', 'lh', 'ld', 'lw', 'lo', 'lts', 'ltm', 'lth', 'ltd', 'ltw', 'lto']
                .forEach(function (claim) {
                    if (claims[claim]) {
                        newClaims[claim] = claims[claim];
                    }
                });

            if (options.host !== false && (options.host || claims.host)) {
                newClaims.host = options.host || claims.host;
            }
            if (typeof options.parseBody !== 'undefined' || typeof options.parse !== 'undefined' || claims.pb) {
                newClaims.pb = +(options.parseBody || options.parse || claims.pb);
            }
            if (typeof options.mergeBody !== 'undefined' || typeof options.merge !== 'undefined' || claims.mb) {
                newClaims.mb = +(options.mergeBody || options.merge || claims.mb);
            }
            if (options.secrets !== false && (options.secrets || claims.ectx)) {
                newClaims.ectx = options.secrets || claims.ectx;
            }
            if (options.params !== false && (options.params || claims.pctx)) {
                newClaims.pctx = options.params || claims.pctx;
            }
            if (options.meta !== false && (options.meta || claims.meta)) {
                newClaims.meta = options.meta || claims.meta;
            }
            if (options.url) {
                newClaims.url = options.url;
            }
            if (options.code) {
                newClaims.code = options.code;
            }

            return self.createRaw(newClaims, { include_webtask_url: options.include_webtask_url });
        }
    }
};

/**
 * List named webtasks from the webtask container
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Sandbox.prototype.listWebtasks = function (options, cb) {
    if (!options) options = {};

    var url = this.url + '/api/webtask/'
        + (options.container || this.container);
    var request = Superagent
        .get(url)
        .set('Authorization', 'Bearer ' + this.token)
        .accept('json');

    if (options.offset) request.query({ offset: options.offset });
    if (options.limit) request.query({ limit: options.limit });
    if (options.meta) {
        for (var m in options.meta) {
            request.query({ meta: m + ':' + options.meta[m] });
        }
    }

    var self = this;
    var promise = this.issueRequest(request)
        .get('body')
        .map(function (webtask) {
            return new Webtask(self, webtask.token, { name: webtask.name, meta: webtask.meta, webtask_url: webtask.webtask_url });
        });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Create a cron job from an already-existing webtask
 *
 * @param {Object} options - Options for creating a cron job
 * @param {String} [options.container] - The container in which the job will run. Defaults to the current profile's container.
 * @param {String} options.name - The name of the cron job.
 * @param {String} [options.token] - The webtask token that will be used to run the job.
 * @param {String} options.schedule - The cron schedule that will be used to determine when the job will be run.
 * @param {String} options.tz - The cron timezone (IANA timezone).
 * @param {String} options.meta - The cron metadata (set of string key value pairs).
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with a {@see CronJob} instance.
 */
Sandbox.prototype.createCronJob = function (options, cb) {
    options = defaults(options, { container: this.container });

    var payload = {
        schedule: options.schedule,
    };
    if (options.token) payload.token = options.token;

    if (options.state) {
        payload.state = options.state;
    }
    if (options.meta) {
        payload.meta = options.meta;
    }
    if (options.tz) {
        payload.tz = options.tz;
    }

    var request = Superagent
        .put(this.url + '/api/cron/' + options.container + '/' + options.name)
        .set('Authorization', 'Bearer ' + this.token)
        .send(payload)
        .accept('json');

    var promise = this.issueRequest(request)
        .get('body')
        .then(this._createCronJob.bind(this));

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Remove an existing cron job
 *
 * @param {Object} options - Options for removing the cron job
 * @param {String} [options.container] - The container in which the job will run. Defaults to the current profile's container.
 * @param {String} options.name - The name of the cron job.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the response from removing the job.
 */
Sandbox.prototype.removeCronJob = function (options, cb) {
    options = defaults(options, { container: this.container });

    var request = Superagent
        .del(this.url + '/api/cron/' + options.container + '/' + options.name)
        .set('Authorization', 'Bearer ' + this.token)
        .accept('json');

    var promise = this.issueRequest(request)
        .get('body');

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Set an existing cron job's state
 *
 * @param {Object} options - Options for updating the cron job's state
 * @param {String} [options.container] - The container in which the job will run. Defaults to the current profile's container.
 * @param {String} options.name - The name of the cron job.
 * @param {String} options.state - The new state of the cron job.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the response from removing the job.
 */
Sandbox.prototype.setCronJobState = function (options, cb) {
    options = defaults(options, { container: this.container });

    var request = Superagent
        .put(this.url + '/api/cron/' + options.container + '/' + options.name + '/state')
        .set('Authorization', 'Bearer ' + this.token)
        .send({
            state: options.state,
        })
        .accept('json');

    var promise = this.issueRequest(request)
        .get('body');

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * List cron jobs associated with this profile
 *
 * @param {Object} [options] - Options for listing cron jobs.
 * @param {String} [options.container] - The container in which the job will run. Defaults to the current profile's container.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with an Array of {@see CronJob} instances.
 */
Sandbox.prototype.listCronJobs = function (options, cb) {
    if (typeof options === 'function') {
        cb = options;
        options = null;
    }

    if (!options) options = {};

    options = defaults(options, { container: this.container });

    var request = Superagent
        .get(this.url + '/api/cron/' + options.container)
        .set('Authorization', 'Bearer ' + this.token)
        .accept('json');

    if (options.offset) request.query({ offset: options.offset });
    if (options.limit) request.query({ limit: options.limit });
    if (options.meta) {
        for (var m in options.meta) {
            request.query({ meta: m + ':' + options.meta[m] });
        }
    }

    var promise = this.issueRequest(request)
        .get('body')
        .map(this._createCronJob.bind(this));

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Get a CronJob instance associated with an existing cron job
 *
 * @param {Object} options - Options for retrieving the cron job.
 * @param {String} [options.container] - The container in which the job will run. Defaults to the current profile's container.
 * @param {String} options.name - The name of the cron job.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with a {@see CronJob} instance.
 */
Sandbox.prototype.getCronJob = function (options, cb) {
    options = defaults(options, { container: this.container });

    var request = Superagent
        .get(this.url + '/api/cron/' + options.container + '/' + options.name)
        .set('Authorization', 'Bearer ' + this.token)
        .accept('json');

    var promise = this.issueRequest(request)
        .get('body')
        .then(this._createCronJob.bind(this));

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Get the historical results of executions of an existing cron job.
 *
 * @param {Object} options - Options for retrieving the cron job.
 * @param {String} [options.container] - The container in which the job will run. Defaults to the current profile's container.
 * @param {String} options.name - The name of the cron job.
 * @param {String} [options.offset] - The offset to use when paging through results.
 * @param {String} [options.limit] - The limit to use when paging through results.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with an Array of cron job results.
 */
Sandbox.prototype.getCronJobHistory = function (options, cb) {
    options = defaults(options, { container: this.container });

    var request = Superagent
        .get(this.url + '/api/cron/' + options.container + '/' + options.name + '/history')
        .set('Authorization', 'Bearer ' + this.token)
        .accept('json');

    if (options.offset) request.query({offset: options.offset});
    if (options.limit) request.query({limit: options.limit});

    var promise = this.issueRequest(request)
        .get('body')
        .map(function (result) {
            var auth0HeaderRx = /^x-auth0/;

            result.scheduled_at = new Date(result.scheduled_at);
            result.started_at = new Date(result.started_at);
            result.completed_at = new Date(result.completed_at);

            for (var header in result.headers) {
                if (auth0HeaderRx.test(header)) {
                    try {
                        result.headers[header] = JSON.parse(result.headers[header]);
                    } catch (__) {
                        // Do nothing
                    }
                }
            }

            return result;
        });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Inspect an existing webtask token to resolve code and/or secrets
 *
 * @param {Object} options - Options for inspecting the webtask.
 * @param {Boolean} options.token - The token that you would like to inspect.
 * @param {Boolean} [options.decrypt] - Decrypt the webtask's secrets.
 * @param {Boolean} [options.fetch_code] - Fetch the code associated with the webtask.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the resolved webtask data.
 */
Sandbox.prototype.inspectToken = function (options, cb) {
    options = defaults(options, { container: this.container });

    var request = Superagent
        .get(this.url + '/api/tokens/inspect')
        .query({ token: options.token })
        .set('Authorization', 'Bearer ' + this.token)
        .accept('json');

    if (options.decrypt) request.query({ decrypt: options.decrypt });
    if (options.fetch_code) request.query({ fetch_code: options.fetch_code });
    if (options.meta) request.query({ meta: options.meta });

    var promise = this.issueRequest(request)
        .get('body');

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Inspect an existing named webtask to resolve code and/or secrets
 *
 * @param {Object} options - Options for inspecting the webtask.
 * @param {Boolean} options.name - The named webtask that you would like to inspect.
 * @param {Boolean} [options.decrypt] - Decrypt the webtask's secrets.
 * @param {Boolean} [options.fetch_code] - Fetch the code associated with the webtask.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the resolved webtask data.
 */
Sandbox.prototype.inspectWebtask = function (options, cb) {
    options = defaults(options, { container: this.container });

    var promise = this.securityVersion === 'v2'
        ? this.getWebtask({ name: options.name, decrypt: options.decrypt, fetch_code: options.fetch_code })
        : this.getWebtask({ name: options.name })
            .call('inspect', { decrypt: options.decrypt, fetch_code: options.fetch_code, meta: options.meta });

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Revoke a webtask token
 *
 * @param {String} token - The token that should be revoked
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 * @see https://webtask.io/docs/api_revoke
 */
Sandbox.prototype.revokeToken = function (token, cb) {
    var request = Superagent
        .post(this.url + '/api/tokens/revoke')
        .set('Authorization', 'Bearer ' + this.token)
        .query({ token: token });

    var promise = this.issueRequest(request);

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * List versions of a given node module that are available on the platform
 *
 * @param {Object} options - Options
 * @param {String} options.name - Name of the node module
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with the token
 */
Sandbox.prototype.listNodeModuleVersions = function (options, cb) {
    var request = Superagent
        .get(this.url + `/api/env/node/modules/${encodeURIComponent(options.name)}`);

    var promise = this.issueRequest(request)
        .get('body');

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Ensure that a set of modules are available on the platform
 *
 * @param {Object} options - Options
 * @param {Array} options.modules - Array of { name, version } pairs
 * @param {Boolean} options.reset - Trigger a rebuild of the modules (Requires administrative token)
 * @param {Function} [cb] - Optional callback function for node-style callbacks
 * @returns {Promise} A Promise that will be fulfilled with an array of { name, version, state } objects
*/
Sandbox.prototype.ensureNodeModules = function (options, cb) {
    var request = Superagent
        .post(this.url + '/api/env/node/modules')
        .send({ modules: options.modules })
        .set('Authorization', 'Bearer ' + this.token);

    if (options.reset) {
        request.query({ reset: 1 });
    }

    var promise = this.issueRequest(request)
        .get('body');

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Update the storage associated to the a webtask
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the webtask.
 * @param {Object} storage - storage
 * @param {Object} storage.data - The data to be stored
 * @param {String} storage.etag - Pass in an optional string to be used for optimistic concurrency control to prevent simultaneous updates of the same data.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Sandbox.prototype.updateStorage = function (storage, options, cb) {
    var promise;

    if (!options || !options.name) {
        var err = new Error('Missing required option: `options.name`');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }
    else {
        var obj = {
            data: JSON.stringify(storage.data)
        }

        if (storage.etag) {
            obj.etag = storage.etag.toString();
        }

        var request = Superagent
            .put(this.url + '/api/webtask/' + (options.container || this.container) + '/' + options.name + '/data')
            .send(obj)
            .set('Authorization', 'Bearer ' + this.token)
            .accept('json');

        promise = this.issueRequest(request)
            .get('body');
    }

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Read the storage associated to the a webtask
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the webtask.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Sandbox.prototype.getStorage = function (options, cb) {
    var promise;

    if (!options || !options.name) {
        var err = new Error('Missing required option: `options.name`');
        err.statusCode = 400;

        promise = Bluebird.reject(err);
    }
    else {
        var request = Superagent
            .get(this.url + '/api/webtask/' + (options.container || this.container) + '/' + options.name + '/data')
            .set('Authorization', 'Bearer ' + this.token)
            .accept('json');

        promise = this.issueRequest(request)
            .get('body')
            .then(function (result) {
                var storage = result;

                try {
                    storage.data = JSON.parse(result.data);
                } catch(e) {
                    // TODO: Log somewhere
                }

                return storage;
            });
    }

    return cb ? promise.nodeify(cb) : promise;
}

Sandbox.limits = {
    container: {
        second: 'ls',
        minute: 'lm',
        hour: 'lh',
        day: 'ld',
        week: 'lw',
        month: 'lo'
    },
    token: {
        second: 'lts',
        minute: 'ltm',
        hour: 'lth',
        day: 'ltd',
        week: 'ltw',
        month: 'lto',
    },
};

/**
 * Create a Sandbox instance from a webtask token
 *
 * @param {String} token - The webtask token from which the Sandbox profile will be derived.
 * @param {Object} options - The options for creating the Sandbox instance that override the derived values from the token.
 * @param {String} [options.url] - The url of the webtask cluster. Defaults to the public 'sandbox.auth0-extend.com' cluster.
 * @param {String} options.container - The container with which this Sandbox instance should be associated. Note that your Webtask token must give you access to that container or all operations will fail.
 * @param {String} options.token - The Webtask Token. See: https://webtask.io/docs/api_issue.
 * @returns {Sandbox} A {@see Sandbox} instance whose url, token and container were derived from the given webtask token.
 *
 * @alias module:sandboxjs.fromToken
 */
Sandbox.fromToken = function (token, options) {
    var config = defaults({}, options, Sandbox.optionsFromJwt(token));

    return Sandbox.init(config);
};

/**
 * Create a Sandbox instance
 *
 * @param {Object} options - The options for creating the Sandbox instance.
 * @param {String} [options.url] - The url of the webtask cluster. Defaults to the public 'sandbox.auth0-extend.com' cluster.
 * @param {String} options.container - The container with which this Sandbox instance should be associated. Note that your Webtask token must give you access to that container or all operations will fail.
 * @param {String} options.token - The Webtask Token. See: https://webtask.io/docs/api_issue.
 * @returns {Sandbox} A {@see Sandbox} instance.
 *
 * @alias module:sandboxjs.init
 */
Sandbox.init = function (options) {
    if (typeof options !== 'object') throw new Error('Expecting an options Object, got `' + typeof options + '`.');
    if (!options.container) throw new Error('A Sandbox instance cannot be created without a container.');
    if (typeof options.container !== 'string') throw new Error('Only String containers are supported, got `' + typeof options.container + '`.');
    if (typeof options.token !== 'string') throw new Error('A Sandbox instance cannot be created without a token.');

    defaults(options, {
        url: 'https://sandbox.auth0-extend.com',
    });

    return new Sandbox(options);
};

Sandbox.optionsFromJwt = function (jwt) {
    var claims = Decode(jwt);

    if (!claims) throw new Error('Unable to decode token `' + jwt + '` (https://jwt.io/#id_token=' + jwt + ').');

    // What does the
    var ten = claims.ten;

    if (!ten) throw new Error('Invalid token, missing `ten` claim `' + jwt + '` (https://jwt.io/#id_token=' + jwt + ').');

    if (Array.isArray(ten)) {
        ten = ten[0];
    } else {
        // Check if the `ten` claim is a RegExp
        var matches = ten.match(/\/(.+)\//);
        if (matches) {
            try {
                var regex = new RegExp(matches[1]);
                var gen = new RandExp(regex);

                // Monkey-patch RandExp to be deterministic
                gen.randInt = function (l) { return l; };

                ten = gen.gen();
            } catch (err) {
                throw new Error('Unable to derive containtainer name from `ten` claim `' + claims.ten + '`: ' + err.message + '.');
            }
        }
    }

    if (typeof ten !== 'string' || !ten) throw new Error('Expecting `ten` claim to be a non-blank string, got `' + typeof ten + '`, with value `' + ten + '`.');

    return {
        container: ten,
        token: jwt,
    };
};


var Bluebird = require('bluebird');
var Decode = require('jwt-decode');
var Path = require('path');
var Superagent = require('superagent');
var Url = require('url');

var defaults = require('lodash.defaults');
var defaultsDeep = require('lodash.defaultsdeep');
var forEach = require('lodash.foreach');
var getFrom = require('lodash.get');

/**
 * Creates an object representing a Webtask
 *
 * @constructor
 */
function Webtask (sandbox, token, options) {
    if (!options) options = {};

    if (sandbox.securityVersion === 'v1') {
        try {
            /**
             * @property claims - The claims embedded in the Webtask's token
             */
            this.claims = Decode(token);

            /**
             * @property token - The token associated with this webtask
             */
            this.token = token;
        }
        catch (_) {
            throw new Error('token must be a valid JWT');
        }
    }

    if (sandbox.securityVersion === 'v2') {
        if (typeof options.name !== 'string') {
            throw new Error('name must be a valid string');
        }

        this.claims = {
            jtn: options.name,
            ten: options.container || sandbox.container,
        }
    }

    /**
     * @property sandbox - The {@see Sandbox} instance used to create this Webtask instance
     */
    this.sandbox = sandbox;

    /**
     * @property meta - The metadata associated with this webtask
     */
    this.meta = options.meta || {};

    /**
     * @property secrets - The secrets associated with this webtask if `decrypt=true`
     */
    this.secrets = options.secrets;

    /**
     * @property code - The code associated with this webtask if `fetch_code=true`
     */
    this.code = options.code;

    /**
     * @property container - The container name in which the webtask will run
     */
    Object.defineProperty(this, 'container', {
        enumerable: true,
        get: function () {
            return options.container || this.sandbox.container;
        }
    });

    /**
     * @property url - The public url that can be used to invoke this webtask
     */
    Object.defineProperty(this, 'url', {
        enumerable: true,
        get: function () {
            var url = options.webtask_url;
            if (!url) {
                if (this.claims.host) {
                   var surl = Url.parse(this.sandbox.url);
                   url = surl.protocol + '//' + this.claims.host + (surl.port ? (':' + surl.port) : '') + '/' + this.sandbox.container;
                }
                else {
                   url = this.sandbox.url + '/api/run/' + this.sandbox.container;
                }

                if (this.claims.jtn) url += '/' + this.claims.jtn;
                else url += '?key=' + this.token;
            }

            return url;
        }
    });
}

/**
 * Create a stream of logs from the webtask container
 *
 * Note that the logs will include messages from our infrastructure.
 *
 * @param {Object} options - Streaming options overrides
 * @param {String} [options.container] - The container for which you would like to stream logs. Defaults to the current profile's container.
 * @returns {Stream} A stream that will emit 'data' events with container logs
 */
Webtask.prototype.createLogStream = function (options) {
    return this.sandbox.createLogStream(options);
};

/**
 * Run the webtask and return the result of execution
 *
 * @param {Object} options - Options used to tweak how the webtask will be invoked
 * @param {Function} [cb] - Optional node-style callback that will be invoked upon completion
 * @returns {Promise} - A Promise that will be resolved with the response from the server.
 */
Webtask.prototype.run = function (options, cb) {
    var methodMap = {
        'delete': 'del',
    };
    var urlData = Url.parse(this.url, true);
    var config = defaultsDeep(options || {}, {
        method: 'get',
        path: '',
        query: {},
    });
    var method = getFrom(methodMap, config.method, config.method);
    var query = defaults(urlData.query, config.query);

    urlData.pathname = Path.join(urlData.pathname, config.path);
    urlData.query = {};
    urlData.search = null;

    var url = Url.format(urlData);
    var request = Superagent[method](url)
        .query(query);

    if (config.body) request = request.send(config.body);

    forEach(config.headers, function (value, header) {
        request = request.set(header, value);
    });

    var promise = Bluebird.resolve(request)
        .catch(function (err) {
            if (err.response) return err.response;

            throw err;
        })
        .then(function (res) {
            res.statusCode = res.status;

            return res;
        });

    return cb ? promise.nodeify(cb, {spread: true}) : promise;
};

/**
 * Schedule the webtask to run periodically
 *
 * @param {Object} options - Options for creating the webtask
 * @param {Object} options.schedule - Cron-string-formatted schedule
 * @param {Object} [options.name] - The name for the cron job
 * @param {Object} [options.tz] - The timezone for the cron job (IANA timezone)
 * @param {Function} [cb] - Optional node-style callback that will be invoked upon completion
 * @returns {Promise} - A Promise that will be resolved with a {@see CronJob} instance.
 */
Webtask.prototype.createCronJob = function (options, cb) {
    if (this.token) {
        var parsedUrl = Url.parse(this.claims.url);
        var filename = parsedUrl.pathname
            ?   Path.basename(parsedUrl.pathname)
            :   '';

        options = defaultsDeep(options, {
            name: this.claims.jtn || filename,
            state: 'active',
            meta: this.meta,
        });
    }
    else {
        if (options.name && options.name !== this.claims.jtn) {
            throw new Error('Server does not support specifying CRON name that is different from webtask name.');
        }
        options = defaultsDeep(options, {
            name: this.claims.jtn,
            state: 'active',
            meta: this.meta,
        });
    }

    if (!options.name) throw new Error('Cron jobs must have a name.');

    var promise = this.sandbox.createCronJob({
        name: options.name,
        token: this.token,
        schedule: options.schedule,
        state: options.state,
        tz: options.tz,
        meta: options.meta
    });

    return cb ? promise.nodeify(cb, {spread: true}) : promise;
};

/**
 * Inspect an existing webtask to optionally get code and/or secrets
 *
 * @param {Object} options - Options for inspecting the webtask.
 * @param {Boolean} [options.decrypt] - Decrypt the webtask's secrets.
 * @param {Boolean} [options.fetch_code] - Fetch the code associated with the webtask.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the result of inspecting the token.
 */
Webtask.prototype.inspect = function (options, cb) {
    if (this.sandbox.securityVersion === 'v2') {
        throw new Error('The Webtask.inspect function is not supported in the current configuration. Consider using Sandbox.inspectWebtask instead.')
    }

    options.token = this.token;

    var promise = this.sandbox.inspectToken(options);

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Remove the named webtask
 *
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the result of inspecting the token.
 */
Webtask.prototype.remove = function (cb) {
    var promise;

    if (!this.claims.jtn) {
        var err = new Error('Unnamed webtasks cannot be removed');
        err.statusCode = 400;

        promise = Bluebird.reject(err);

        return cb ? promise.nodeify(cb) : promise;
    } else {
        promise = this.sandbox.removeWebtask({
            name: this.claims.jtn,
        });
    }

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Revoke the webtask's token
 *
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the result of revoking the token.
 */
Webtask.prototype.revoke = function (cb) {
    var promise = this.sandbox.revokeToken(this.token);

    return cb ? promise.nodeify(cb) : promise;
};

/**
 * Update a webtask
 *
 * @param {Object} [options] - Options for updating a webtask (@see: Sandbox.updateWebtask)
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @returns {Promise} A Promise that will be fulfilled with the result of revoking the token.
 */
Webtask.prototype.update = function (options, cb) {
    if (typeof options === 'function') {
        cb = options;
        options = {};
    }

    if (!options) {
        options = {};
    }

    options.name = this.claims.jtn;

    var promise = this.sandbox.updateWebtask(options);

    return cb ? promise.nodeify(cb) : promise;
};

Webtask.prototype.toJSON = function () {
    var data = {
        container: this.container,
        token: this.token,
        url: this.url,
    };

    if (this.claims.jtn) data.name = this.claims.jtn;

    return data;
};

/**
 * Update the storage associated to the a webtask
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the webtask.
 * @param {Object} storage - storage
 * @param {Object} storage.data - The data to be stored
 * @param {String} storage.etag - Pass in an optional string to be used for optimistic concurrency control to prevent simultaneous updates of the same data.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Webtask.prototype.updateStorage = function (storage, options, cb) {
    var promise = this.sandbox.updateStorage(storage, options);

    return cb ? promise.nodeify(cb) : promise;
}

/**
 * Read the storage associated to the a webtask
 *
 * @param {Object} options - Options
 * @param {String} [options.container] - Set the webtask container. Defaults to the profile's container.
 * @param {String} options.name - The name of the webtask.
 * @param {Function} [cb] - Optional callback function for node-style callbacks.
 * @return {Promise} A Promise that will be fulfilled with an array of Webtasks
 */
Webtask.prototype.getStorage = function (options, cb) {
    var promise = this.sandbox.getStorage(options);

    return cb ? promise.nodeify(cb) : promise;
}

module.exports = Webtask;


/* jshint node: true */
'use strict';

var util = require('util'),
    EventEmitter = require('events').EventEmitter;

var Unpi = require('unpi'),
    Serialport = require('serialport'),
    debug = require('debug')('cc-znp'),
    logSreq = require('debug')('cc-znp:SREQ'),
    logSrsp = require('debug')('cc-znp:SRSP'),
    logAreq = require('debug')('cc-znp:AREQ');

var zmeta = require('./zmeta'),
    ZpiObject = require('./zpiObject');

var MT = {
    CMDTYPE: zmeta.CmdType,
    SUBSYS: zmeta.Subsys,
    SYS: zmeta.SYS,
    MAC: zmeta.MAC,
    AF: zmeta.AF,
    ZDO: zmeta.ZDO,
    SAPI: zmeta.SAPI,
    UTIL: zmeta.UTIL,
    DBG: zmeta.DBG,
    APP: zmeta.APP,
    DEBUG: zmeta.DEBUG
};

/*************************************************************************************************/
/*** Polyfill                                                                                ***/
/*************************************************************************************************/
if (!EventEmitter.prototype.listenerCount ) {
  EventEmitter.prototype.listenerCount = function (type){
    var events = this._events;

    if (events) {
      var evlistener = events[type];

      if (typeof evlistener === 'function') {
        return 1;
      } else if (evlistener) {
        return evlistener.length;
      }
    }

    return 0;
  };
}

/*************************************************************************************************/
/*** CcZnp Class                                                                               ***/
/*************************************************************************************************/
function CcZnp () {
    EventEmitter.call(this);

    var self = this;

    this.MT = MT;       // export constant

    this._init = false;
    this._resetting = false;
    this._sp = null;
    this._unpi = null;
    this._spinLock = false;
    this._txQueue = [];

    this.on('_ready', function () {
        self._init = true;
        self.emit('ready');
    });

    this._innerListeners = {
        spOpen: function () {
            debug('The serialport ' + self._sp.path + ' is opened.');
            self.emit('_ready');
        },
        spErr: function (err) {
            self._sp.close();
        },
        spClose: function () {
            debug('The serialport ' + self._sp.path + ' is closed.');
            self._txQueue = null;
            self._txQueue = [];
            self._sp = null;
            self._unpi = null;
            self._init = false;
            self.emit('close');
        },
        parseMtIncomingData: function (result) {
            self._parseMtIncomingData(result);
        }
    };
}

util.inherits(CcZnp, EventEmitter);

var ccznp = new CcZnp();

/*************************************************************************************************/
/*** Public APIs                                                                               ***/
/*************************************************************************************************/
CcZnp.prototype.init = function (spCfg, callback) {
    if (typeof spCfg !== 'object' || Array.isArray(spCfg))
        throw new TypeError('spCfg should be a plain object.');

    if (!spCfg.options)
        spCfg.options = { autoOpen: false };
    else
        spCfg.options.autoOpen = false;

    callback = callback || function () {};

    var self = this,
        sp = this._sp = (this._sp instanceof Serialport) ? this._sp : new Serialport(spCfg.path, spCfg.options),
        unpi = this._unpi = (this._unpi instanceof Unpi) ? this._unpi : new Unpi({ lenBytes: 1, phy: sp });

    // Listeners for inner use
    var parseMtIncomingData = this._innerListeners.parseMtIncomingData,
        spOpenLsn = this._innerListeners.spOpen,
        spErrLsn = this._innerListeners.spErr,
        spCloseLsn = this._innerListeners.spClose;

    if (!sp)
        throw new Error('Cannot initialize serial port.');

    if (!unpi)
        throw new Error('Cannot initialize unpi.');

    // remove all inner listeners were attached on last init
    unpi.removeListener('data', parseMtIncomingData);
    sp.removeListener('open', spOpenLsn);
    sp.removeListener('error', spErrLsn);
    sp.removeListener('close', spCloseLsn);

    // re-attach inner listeners
    unpi.on('data', parseMtIncomingData);
    sp.once('open', spOpenLsn);

    sp.open(function (err) {
        if (err)
            return callback(err);

        sp.on('error', spErrLsn);
        sp.on('close', spCloseLsn);
        callback(null);
    });
};

CcZnp.prototype.close = function (callback) {
    var self = this;

    if (this._init) {
        this._sp.flush(function () {
            self._sp.close(callback);
        });
    } else {
        callback(null);
    }
};

CcZnp.prototype.request = function (subsys, cmd, valObj, callback) {
    // subsys: String | Number, cmd: String | Number, valObj: Object | Array
    var self = this,
        argObj,
        payload;

    if (!this._init)
        throw new Error('ccznp has not been initialized yet');

    if (this._spinLock) {
        this._txQueue.push(function () {
            self.request(subsys, cmd, valObj, callback);
        });
        return;
    }

    // prepare for transmission
    this._spinLock = true;

    // validations
    if (!valObj || typeof valObj !== 'object')
        throw new TypeError('valObj should be an object');
    else if ((typeof callback !== 'function') && (typeof callback !== 'undefined'))
        throw new TypeError('callback should be a function');
    else
        argObj = new ZpiObject(subsys, cmd, valObj);

    if (argObj.type === 'SREQ') {
        logSreq('--> %s, %o', argObj.subsys + ':' + argObj.cmd, valObj);
        return this._sendSREQ(argObj, callback);
    } else if (argObj.type === 'AREQ') {
        logAreq('--> %s, %o', argObj.subsys + ':' + argObj.cmd, valObj);
        return this._sendAREQ(argObj, callback);
    }
};

CcZnp.prototype.sendCmd = function (type, subsys, cmdId, payload) {
    return this._unpi.send(type, subsys, cmdId, payload);
};

/*********************************/
/*** Create Request Shorthands ***/
/*********************************/
// example: ccznp.sysRequest(), ccznp.zdoRequest()
var namespaces = [ 'SYS', 'MAC', 'NWK', 'AF', 'ZDO', 'SAPI', 'UTIL', 'DBG', 'APP', 'DEBUG' ];

namespaces.forEach(function (subsys) {
    var reqMethod = subsys.toLowerCase() + 'Request';
    CcZnp.prototype[reqMethod] = function (cmdId, valObj, callback) {
        return this.request(subsys, cmdId, valObj, callback);
    };
});

/*************************************************************************************************/
/*** Protected Methods                                                                         ***/
/*************************************************************************************************/

CcZnp.prototype._sendSREQ = function (argObj, callback) {
    // subsys: String, cmd: String
    var self = this,
        payload = argObj.frame(),
        sreqTimeout,
        srspEvt = 'SRSP:' + argObj.subsys + ':' + argObj.cmd;

    if (!payload) {
        callback(new Error('Fail to build frame'));
        return;
    }

    sreqTimeout = setTimeout(function () {
        if (self.listenerCount(srspEvt))
            self.emit(srspEvt, '__timeout__');

        sreqTimeout = null;
    }, 3000);

    // attach response listener
    this.once(srspEvt, function (result) {
        self._spinLock = false;

        // clear timeout controller if it is there
        if (sreqTimeout) {
            clearTimeout(sreqTimeout);
            sreqTimeout = null;
        }

        // schedule next transmission if something in txQueue
        self._scheduleNextSend();

        // check if this event is fired by timeout controller
        if (result === '__timeout__') {
            logSrsp('<-- %s, __timeout__', argObj.subsys + ':' + argObj.cmd);
            callback(new Error('request timeout'));
        } else {
            self._resetting = false;
            callback(null, result);
        }
    });

    this._unpi.send('SREQ', argObj.subsys, argObj.cmdId, payload);
};

CcZnp.prototype._sendAREQ = function (argObj, callback) {
    // subsys: String, cmd: String
    var self = this,
        payload = argObj.frame();

    if (!payload) {
        callback(new Error('Fail to build frame'));
        return;
    }

    if (argObj.cmd === 'resetReq' || argObj.cmd === 'systemReset') {
        this._resetting = true;
        // clear all pending requests, since the system is reset
        this._txQueue = null;
        this._txQueue = [];

        this.once('AREQ:SYS:RESET', function () {
            // hold the lock until coordinator reset completed
            self._resetting = false;
            self._spinLock = false;
            callback(null);
        });

        // if AREQ:SYS:RESET does not return in 30 sec
        // release the lock to avoid the requests from enqueuing
        setTimeout(function () {
            if (self._resetting)
                self._spinLock = false;
        }, 30000);

    } else {
        this._spinLock = false;
        this._scheduleNextSend();
        callback(null);
    }

    this._unpi.send('AREQ', argObj.subsys, argObj.cmdId, payload);
};

CcZnp.prototype._scheduleNextSend = function () {
    var txQueue = this._txQueue;

    if (txQueue.length) {
        setImmediate(function () {
            txQueue.shift()();
        });
    }
};

CcZnp.prototype._parseMtIncomingData = function (data) {
    // data = { sof, len, type, subsys, cmd, payload, fcs, csum }
    var self = this,
        argObj;

    this.emit('data', data);

    try {
        if (data.fcs !== data.csum)
            throw new Error('Invalid checksum');

        argObj = new ZpiObject(data.subsys, data.cmd);
        data.type = zmeta.CmdType.get(data.type).key;    // make sure data.type will be string
        data.subsys = argObj.subsys;                     // make sure data.subsys will be string
        data.cmd = argObj.cmd;                           // make sure data.cmd will be string

        argObj.parse(data.type, data.len, data.payload, function (err, result) {
            data.payload = result;
console.log(data);
            setImmediate(function () {
                self._mtIncomingDataHdlr(err, data);
            });
        });
    } catch (e) {
        self._mtIncomingDataHdlr(e, data);
    }
};

CcZnp.prototype._mtIncomingDataHdlr = function (err, data) {
    // data = { sof, len, type, subsys, cmd, payload = result, fcs, csum }
    if (err) {
        debug(err); // just print out. do nothing if incoming data is invalid
        return;
    }

    var rxEvt,
        msg,
        subsys = data.subsys,
        cmd = data.cmd,
        result = data.payload;

    if (data.type === 'SRSP') {
        logSrsp('<-- %s, %o', subsys + ':' + cmd, result);
        rxEvt = 'SRSP:' + subsys + ':' + cmd;
        this.emit(rxEvt, result);
    } else if (data.type === 'AREQ') {
        logAreq('<-- %s, %o', subsys + ':' + cmd, result);
        rxEvt = 'AREQ';
        msg = {
            subsys: subsys,
            ind: cmd,
            data: result
        };

        this.emit(rxEvt, msg);

        if (subsys === 'SYS' && cmd === 'resetInd') {
            rxEvt = 'AREQ:SYS:RESET';
            this.emit(rxEvt, result);
        }
    }
};

/*************************************************************************************************/
/*** Export as a singleton                                                                     ***/
/*************************************************************************************************/
module.exports = ccznp;