module.exports = {

    insert: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbconnector.insert: connection is required.');
        const sql = this.parseSQL(options.sql);
        const db = this.getDbConnection(connection);

        if (!db) throw new Error(`Connection "${connection}" doesn't exist.`);
        if (!sql) throw new Error('dbconnector.insert: sql is required.');
        if (!sql.table) throw new Error('dbconnector.insert: sql.table is required.');

        sql.type = 'insert';

        if (db.client == 'couchdb') {
            const doc = {};

            for (const value of sql.values) {
                doc[value.column] = value.value;
            }

            const result = await db.insert(doc, sql.table + '/' + Date.now());

            if (result.ok) {
                return { affected: 1, identity: result.id };
            } else {
                //throw new Error('dbconnector.insert: error inserting document into couchdb.');
                return { affected: 0 };
            }
        }

        if (options.test) {
            return {
                options: options,
                query: sql.toString()
            };
        }

        if (sql.sub) {
            return db.transaction(async trx => {
                // TODO: test how identity is returned for each database
                // main insert, returns inserted id
                const [identity] = (await trx.fromJSON(sql)).map(value => value[sql.returning] || value);

                // loop sub (relation table)
                for (let { table, key, value, values } of Object.values(sql.sub)) {
                    if (!Array.isArray(value)) break;

                    for (const current of value) {
                        if (typeof current == 'object') {
                            current[key] = identity;
                            await trx(table).insert(current);
                        } else {
                            if (values.length != 1) throw new Error('Invalid value mapping');
                            await trx(table).insert({
                                [key]: identity,
                                [values[0].column]: current
                            });
                        }
                    }
                }

                return { affected: 1, identity };
            });
        }

        let identity = await db.fromJSON(sql);

        if (identity) {
            if (Array.isArray(identity)) {
                identity = identity[0];
            }

            if (typeof identity == 'object') {
                identity = identity[Object.keys(identity)[0]];
            }
        }

        return { affected: 1, identity };
    },

    update: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbconnector.update: connection is required.');
        const sql = this.parseSQL(options.sql);
        const db = this.getDbConnection(connection);

        if (!db) throw new Error(`Connection "${connection}" doesn't exist.`);
        if (!sql) throw new Error('dbconnector.update: sql is required.');
        if (!sql.table) throw new Error('dbconnector.update: sql.table is required.');

        sql.type = 'update';

        if (db.client == 'couchdb') {
            let { rows } = await db.list({ include_docs: true, startkey: sql.table + '/', endkey: sql.table + '0' });

            rows = rows.map(row => row.doc);

            if (sql.wheres) {
                const validate = (row, rule) => {
                    if (rule.operator) {
                        let a = row[rule.data.column];
                        let b = rule.value;

                        switch (rule.operator) {
                            case 'equal': return a == b;
                            case 'not_equal': return a != b;
                            case 'in': return b.includes(a);
                            case 'not_in': return !b.includes(a);
                            case 'less': return a < b;
                            case 'less_or_equal': return a <= b;
                            case 'greater': return a > b;
                            case 'greater_or_equal': return a >= b;
                            case 'between': return b[0] <= a <= b[1];
                            case 'not_between': return !(b[0] <= a <= b[1]);
                            case 'begins_with': return String(a).startsWith(String(b));
                            case 'not_begins_with': return !String(a).startsWith(String(b));
                            case 'contains': return String(a).includes(String(b));
                            case 'not_contains': return !String(a).includes(String(b));
                            case 'ends_with': return String(a).endsWith(String(b));
                            case 'not_ends_with': return !String(a).endsWith(String(b));
                            case 'is_empty': return a == null || a == '';
                            case 'is_not_empty': return a != null && a != '';
                            case 'is_null': return a == null;
                            case 'is_not_null': return a != null;
                        }
                    }

                    if (rule.condition && rule.rules.length) {
                        for (const _rule of rule.rules) {
                            const valid = validate(row, _rule);
                            if (!valid && rule.condition == 'AND') return false;
                            if (valid && rule.condition == 'OR') return true;
                        }

                        return rule.condition == 'OR' ? false : true;
                    }

                    return true;
                };

                rows = rows.filter(row => {
                    return validate(row, sql.wheres);
                });
            }

            let result = []
            if (rows.length) {
                result = await db.bulk({
                    docs: rows.map(doc => {
                        for (const value of sql.values) {
                            doc[value.column] = value.value;
                        }
                        return doc;
                    })
                });
            }

            return { affected: Array.isArray(result) ? result.filter(result => result.ok).length : rows.length };
        }

        if (options.test) {
            return {
                options: options,
                query: sql.toString()
            };
        }

        if (sql.sub) {
            return db.transaction(async trx => {
                let updated = await trx.fromJSON(sql);

                if (!Array.isArray(updated)) {
                    // check if is single update
                    const single = (
                        sql.wheres &&
                        sql.wheres.rules &&
                        sql.wheres.rules.length == 1 &&
                        sql.wheres.rules[0].field == sql.returning &&
                        sql.wheres.rules[0].operation == '='
                    );

                    if (single) {
                        // get id from where condition
                        updated = [sql.wheres.rules[0].value];
                    } else {
                        // create a select with same where conditions
                        updated = await trx.fromJSON({
                            ...sql,
                            type: 'select',
                            columns: [sql.returning]
                        });

                        updated = updated.map(value => value[sql.returning]);
                    }
                } else {
                    updated = updated.map(value => value[sql.returning]);
                }

                // loop sub
                for (let { table, key, value, values } of Object.values(sql.sub)) {
                    if (!Array.isArray(value)) continue;

                    // delete old related data first
                    await trx(table).whereIn(key, updated).del();

                    // for each updated item
                    for (const identity of updated) {
                        // insert value
                        for (const current of value) {
                            if (typeof current == 'object') {
                                current[key] = identity;
                                await trx(table).insert(current);
                            } else {
                                if (values.length != 1) throw new Error('Invalid value mapping');
                                await trx(table).insert({
                                    [key]: identity,
                                    [values[0].column]: current
                                });
                            }
                        }
                    }
                }

                return { affected: updated.length };
            });
        }

        let affected = await db.fromJSON(sql);

        return { affected };
    },

    delete: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbconnector.delete: connection is required.');
        const sql = this.parseSQL(options.sql);

        const db = this.getDbConnection(connection);

        if (!db) throw new Error(`Connection "${connection}" doesn't exist.`);
        if (!sql) throw new Error('dbconnector.delete: sql is required.');
        if (!sql.table) throw new Error('dbconnector.delete: sql.table is required.');

        sql.type = 'del';

        if (db.client == 'couchdb') {

            let { rows } = await db.list({ include_docs: true, startkey: sql.table + '/', endkey: sql.table + '0' });

            rows = rows.map(row => row.doc);

            if (sql.wheres) {
                const validate = (row, rule) => {
                    if (rule.operator) {
                        let a = row[rule.data.column];
                        let b = rule.value;

                        switch (rule.operator) {
                            case 'equal': return a == b;
                            case 'not_equal': return a != b;
                            case 'in': return b.includes(a);
                            case 'not_in': return !b.includes(a);
                            case 'less': return a < b;
                            case 'less_or_equal': return a <= b;
                            case 'greater': return a > b;
                            case 'greater_or_equal': return a >= b;
                            case 'between': return b[0] <= a <= b[1];
                            case 'not_between': return !(b[0] <= a <= b[1]);
                            case 'begins_with': return String(a).startsWith(String(b));
                            case 'not_begins_with': return !String(a).startsWith(String(b));
                            case 'contains': return String(a).includes(String(b));
                            case 'not_contains': return !String(a).includes(String(b));
                            case 'ends_with': return String(a).endsWith(String(b));
                            case 'not_ends_with': return !String(a).endsWith(String(b));
                            case 'is_empty': return a == null || a == '';
                            case 'is_not_empty': return a != null && a != '';
                            case 'is_null': return a == null;
                            case 'is_not_null': return a != null;
                        }
                    }

                    if (rule.condition && rule.rules.length) {
                        for (const _rule of rule.rules) {
                            const valid = validate(row, _rule);
                            if (!valid && rule.condition == 'AND') return false;
                            if (valid && rule.condition == 'OR') return true;
                        }

                        return rule.condition == 'OR' ? false : true;
                    }

                    return true;
                };

                rows = rows.filter(row => {
                    return validate(row, sql.wheres);
                });
            }

            let result = []
            if (rows.length) {
                result = await db.bulk({
                    docs: rows.map(doc => {
                        doc._deleted = true;
                        return doc;
                    })
                });
            }

            return { affected: Array.isArray(result) ? result.filter(result => result.ok).length : rows.length };
        }

        if (options.test) {
            return {
                options: options,
                query: sql.toString()
            };
        }

        if (sql.sub) {
            return db.transaction(async trx => {
                const deleted = (await trx.fromJSON(sql)).map(value => value[sql.returning] || value);

                // loop sub
                for (let { table, key } of Object.values(sql.sub)) {
                    // delete related data
                    await trx(table).whereIn(key, deleted).del();
                }

                return { affected: deleted.length };
            });
        }

        let affected = await db.fromJSON(sql);

        return { affected };
    },

    custom: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbupdater.custom: connection is required.');
        const sql = this.parseSQL(options.sql);
        const db = this.getDbConnection(connection);

        if (!db) throw new Error(`Connection "${connection}" doesn't exist.`);
        if (!sql) throw new Error('dbconnector.custom: sql is required.');
        if (typeof sql.query != 'string') throw new Error('dbupdater.custom: sql.query is required.');
        if (!Array.isArray(sql.params)) throw new Error('dbupdater.custom: sql.params is required.');

        if (db.client == 'couchdb') {
            throw new Error('dbupdater.custom: couchdb is not supported.');
        }

        const params = [];
        const query = sql.query.replace(/([:@][a-zA-Z_]\w*|\?)/g, param => {
            if (param == '?') {
                params.push(sql.params[params.length].value);
                return '?';
            }

            let p = sql.params.find(p => p.name == param);
            if (p) {
                params.push(p.value);
                return '?';
            }

            return param;
        });

        let results = await db.raw(query, params);

        if (db.client.config.client == 'mysql' || db.client.config.client == 'mysql2') {
            results = results[0];
        } else if (db.client.config.client == 'postgres' || db.client.config.client == 'redshift') {
            results = results.rows;
        }

        return results;
    },

    execute: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbupdater.execute: connection is required.');
        const query = this.parseRequired(options.query, 'string', 'dbupdater.execute: query is required.');
        const params = this.parseOptional(options.params, 'object', []);
        const db = this.getDbConnection(connection);

        if (!db) throw new Error(`Connection "${connection}" doesn't exist.`);

        if (db.client == 'couchdb') {
            throw new Error('dbupdater.execute: couchdb is not supported.');
        }

        let results = await db.raw(query, params);

        if (db.client.config.client == 'mysql' || db.client.config.client == 'mysql2') {
            results = results[0];
        } else if (db.client.config.client == 'postgres' || db.client.config.client == 'redshift') {
            results = results.rows;
        }

        return results;
    },

    // bulk insert
    bulkinsert: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbupdater.bulkinsert: connection is required.');
        const sql = this.parseSQL(options.sql);
        const source = this.parseRequired(options.source, 'object', 'dbupdater.bulkinsert: source is required.');
        const batchsize = this.parseOptional(options.batchsize, 'number', 100);
        const db = this.getDbConnection(connection);

        if (db.client == 'couchdb') {
            throw new Error('dbupdater.bulkinsert: couchdb is not supported.');
        }

        return await db.transaction(async transaction => {
            for (let i = 0; i < source.length; i += batchsize) {
                let batch = source.slice(i, i + batchsize).map(data => {
                    let values = {};

                    for (let value of sql.values) {
                        if (value.type == 'json') {
                            values[value.column] = JSON.stringify(data[value.value]);
                        } else {
                            values[value.column] = data[value.value];
                        }
                    }

                    return values;
                });

                await transaction(sql.table.name || sql.table).insert(batch);
            }

            return { affected: source.length };
        });
    },

    transaction: async function (options) {
        const connection = this.parseRequired(options.connection, 'string', 'dbupdater.transaction: connection is required.');
        //const exec = this.parseRequired(options.exec, 'object', 'dbupdater.transaction: exec is required.');
        const db = this.getDbConnection(connection);

        if (db.client == 'couchdb') {
            throw new Error('dbupdater.transaction: couchdb is not supported.');
        }

        return await db.transaction(async trx => {
            this.trx[connection] = trx;
            return await this.exec(options.exec, true).finally(() => {
                this.trx[connection] = null;
            });
        });
    },

};