'use strict';

module.exports =
{
    // Create an readable stream which returns object
    createObjectStream: function (resultset) {
        return new HanaRSStream(resultset, false);
    },

    // Create an readable stream which returns array
    createArrayStream: function (resultset) {
        return new HanaRSStream(resultset, true);
    },

    // Create a LOB stream
    createLobStream: function (resultset, columnIndex, options) {
        return new HanaLobStream(resultset, columnIndex, options);
    },

    // Create a LOB stream for a LOB output parameter
    createParameterLobStream: function (statement, paramIndex, options) {
        return new HanaParameterLobStream(statement, paramIndex, options);
    },

    // Create a statement which allows user to pass readable streams for input parameters
    createStatement: function (connection, sql, callback) {
        return new HanaStatement(connection, sql, callback);
    },

    // Create a statement for procedure call. After calling exec, output parameters
    // (err, scalarParams, table1, table2...) will be passed to the callback
    createProcStatement: function (connection, sql, callback) {
        return new HanaProcStatement(connection, sql, callback);
    },

    // Execute a statement which returns multiple resultsets. After calling this method, output parameters
    // (err, scalarParams, table1, table2..., columnInfo1, columnInfo2...) will be passed to the callback
    executeMultipleResultSets: function (...args /*connection, sql, params, [options,] callback*/) {
        if (args.length != 4 && args.length != 5) {
            throw new Error("Invalid number of parameters - executeMultipleResultSets(connection, sql, params, [options,] callback).");
        }

        var connection = args[0];
        var sql = args[1];
        var params = args[2];
        if (args.length == 4) {
            var options = {};
            var callback = args[3];
        } else if (args.length == 5) {
            var options = args[3];
            var callback = args[4];
        }

        if (callback == undefined || callback == null || typeof callback !== 'function') {
            throw new Error("Invalid parameter 'callback'.");
        }

        var statement = new HanaProcStatement(connection, sql, function (err, stmt) {
            if (err) {
                callback(err);
            } else {
                let s = stmt;
                stmt.exec(params, options, function (err, ...args) {
                    if (!err) {
                        s.getColumnInfo().forEach(function (ci) {
                            args.push(ci);
                        });
                    }
                    s.drop(function (errDrop) {
                        if (err) {
                            callback(err);
                        } else if (errDrop) {
                            callback(errDrop);
                        } else {
                            args.unshift(undefined); // no error
                            callback.apply(null, args);
                        }
                    });
                });
            }
        });
    },

    // Execute a statement which returns multiple resultsets. After calling this method, output parameters
    // (err, scalarParams, table1, table2..., columnInfo1, columnInfo2...) will be passed to the callback
    executeMultipleResultSets2: function (...args /*stmt, params, [options,] callback*/) {
        if (args.length != 3 && args.length != 4) {
            throw new Error("Invalid number of parameters - executeMultipleResultSets2(stmt, params, [options,] callback).");
        }

        var stmt = args[0];
        var params = args[1];
        if (args.length === 3) {
            var options = {};
            var callback = args[2];
        } else if (args.length === 4) {
            var options = args[2];
            var callback = args[3];
        }

        if (callback == undefined || callback == null || typeof callback !== 'function') {
            throw new Error("Invalid parameter 'callback'.");
        }

        var s = new HanaProcStatement(stmt);
        s.exec(params, options, function (err, ...args) {
            if (err) {
                callback(err);
            } else {
                s.getColumnInfo().forEach(function (ci) {
                    args.push(ci);
                });
                args.unshift(undefined);
                callback.apply(null, args);
            }
        });
    }
};

var util = require('util');
var streamModule = require('stream');
var Stream = streamModule.Stream;
var Readable = streamModule.Readable;

// HanaRSStream is used by both createObjectStream and createArrayStream
function HanaRSStream(resultset, asArray) {
    checkParameter('resultset', resultset)
    Readable.call(this, { objectMode: true });
    resultset.setAsArrayDefault(asArray);
    this.resultset = resultset;
};

util.inherits(HanaRSStream, Readable);

HanaRSStream.prototype._read = function () {
    var stream = this;
    // There are 3 combinations of sync vs async next and getValues.
    // When nextCanBlock is false, the stream is about 2x faster
    // than without this optimization.
    // This implementation might be simpler if promises were used.
    if(this.resultset.nextCanBlock()) {
        // async next
        this.resultset.next(function (err, ret) {
            if (err === undefined) {
                if (ret) {
                    if(stream.resultset.getValuesCanBlock()) {
                        // async getValues
                        stream.resultset.getValues(function (err, ret) {
                            if (err === undefined) {
                                stream.push(ret);
                            } else {
                                stream.emit('error', err);
                            }
                        });
                    } else {
                        // sync getValues
                        try {
                            stream.push(stream.resultset.getValues());
                        } catch(err) {
                            stream.emit('error', err);
                        }
                    }
                } else {
                    stream.push(null);
                }
            } else {
                stream.emit('error', err);
            }
        });
    } else {
        // nextCanBlock returned false, use sync next and getValues.
        try {
            var ret = this.resultset.next();
            if (ret) {
                stream.push(stream.resultset.getValues());
            } else {
                stream.push(null);
            }
        } catch(err) {
            stream.emit('error', err);
        }
    }
};

HanaRSStream.prototype._destroy = function (error, callback) {
    this.push(null);
    if (typeof callback === 'function') {
        callback(error);
    }
};

// Lob stream
function HanaLobStream(resultset, columnIndex, options) {
    checkParameter('resultset', resultset);

    var opts = getLobStreamOptions(options);
    Readable.call(this, opts);

    this.resultset = resultset;
    this.columnInfo = this.resultset.getColumnInfo();
    this.columnIndex = columnIndex;

    checkColumnIndex(this.columnInfo, columnIndex);
    checkLobType(this.columnInfo, columnIndex);

    this.isString = this.columnInfo[this.columnIndex].typeName === 'STRING';
    this.offset = 0;
    this.noPause = opts.noPause;
    this.valueAsString = false;
    if (typeof opts.valueAsString === "boolean") {
        this.valueAsString = opts.valueAsString;
    }
};

util.inherits(HanaLobStream, Readable);

HanaLobStream.prototype._read = function (bufferSize) {
    if (this.resultset.isNull(this.columnIndex)) {
        this.push(null);
        return;
    }

    var stream = this;
    stream.bufferSize = bufferSize;
    var buffer = createBuffer(bufferSize);

    if (!this.noPause) {
        this.pause();
    }

    this.resultset.getData(this.columnIndex, this.offset, buffer, 0, bufferSize, function (err, bytesRetrieved) {
        if (err === undefined) {
            if (stream.isString) {
                if (bytesRetrieved > 0) {
                    let nullChar = buffer.indexOf(0);
                    if (nullChar === 0) {
                        stream.push(null);
                    } else {
                        let newBuffer = buffer;
                        if (nullChar > 0) {
                            newBuffer = buffer.slice(0, nullChar);
                        }
                        let str = newBuffer.toString('utf8');
                        stream.offset += str.length;
                        if (stream.valueAsString) {
                            stream.push(str);
                        } else {
                            stream.push(newBuffer);
                        }
                        // +3 because SQLDBC does not copy partial characters,
                        // and the largest partial UTF-8 character is 3 (since
                        // the largest UTF-8 character is 4 bytes)
                        if (bytesRetrieved + 3 < stream.bufferSize) {
                            stream.push(null);
                        }
                    }
                } else {
                    stream.push(null);
                }
            } else { // LOB
                stream.offset += bytesRetrieved;
                if (bytesRetrieved > 0) {
                    if (bytesRetrieved < stream.bufferSize) {
                        stream.push(buffer.slice(0, bytesRetrieved));
                        stream.push(null);
                    } else {
                        stream.push(buffer);
                    }
                } else {
                    stream.push(null);
                }
            }
        } else {
            if (err.code === 100) { // No more result
                stream.push(null);
            } else {
                stream.emit('error', err);
            }
        }
        if (!stream.noPause) {
            stream.resume();
        }
    });
};

HanaLobStream.prototype._destroy = function (error, callback) {
    this.push(null);
    if (typeof callback === 'function') {
        callback(error);
    }
};

// Parameter lob stream
function HanaParameterLobStream(statement, paramIndex, options) {
    checkParameter('statement', statement);

    var opts = getLobStreamOptions(options);
    Readable.call(this, opts);

    this.statement = statement;
    this.paramInfo = this.statement.getParameterInfo();
    this.paramIndex = paramIndex;

    checkParameterIndex(this.paramInfo, paramIndex);
    checkParameterLobType(this.paramInfo, paramIndex);

    this.isString = this.paramInfo[this.paramIndex].typeName === 'STRING';
    this.offset = 0;
    this.noPause = opts.noPause;
    this.valueAsString = false;
    if (typeof opts.valueAsString === "boolean") {
        this.valueAsString = opts.valueAsString;
    }
};

util.inherits(HanaParameterLobStream, Readable);

HanaParameterLobStream.prototype._read = function (bufferSize) {
    if (this.statement.isParameterNull(this.paramIndex)) {
        this.push(null);
        return;
    }

    var stream = this;
    stream.bufferSize = bufferSize;
    var buffer = createBuffer(bufferSize);

    if (!this.noPause) {
        this.pause();
    }

    this.statement.getData(this.paramIndex, this.offset, buffer, 0, bufferSize, function (err, bytesRetrieved) {
        if (err === undefined) {
            if (stream.isString) {
                if (bytesRetrieved > 0) {
                    let nullChar = buffer.indexOf(0);
                    if (nullChar === 0) {
                        stream.push(null);
                    } else {
                        let newBuffer = buffer;
                        if (nullChar > 0) {
                            newBuffer = buffer.slice(0, nullChar);
                        }
                        let str = newBuffer.toString('utf8');
                        stream.offset += str.length;
                        if (stream.valueAsString) {
                            stream.push(str);
                        } else {
                            stream.push(newBuffer);
                        }
                        if (bytesRetrieved < stream.bufferSize) {
                            stream.push(null);
                        }
                    }
                } else {
                    stream.push(null);
                }
            } else { // LOB
                stream.offset += bytesRetrieved;
                if (bytesRetrieved > 0) {
                    if (bytesRetrieved < stream.bufferSize) {
                        stream.push(buffer.slice(0, bytesRetrieved));
                        stream.push(null);
                    } else {
                        stream.push(buffer);
                    }
                } else {
                    stream.push(null);
                }
            }
        } else {
            if (err.code === 100) { // No more result
                stream.push(null);
            } else {
                stream.emit('error', err);
            }
        }
        if (!stream.noPause) {
            stream.resume();
        }
    });
};

HanaParameterLobStream.prototype._destroy = function (error, callback) {
    this.push(null);
    if (typeof callback === 'function') {
        callback(error);
    }
};

// Statement which allows user to pass readable streams for input parameters
function HanaStatement(connection, sql, callback) {
    if (connection === undefined || connection === null) {
        handleError("Invalid parameter 'connection'.", callback);
        return;
    }
    if (sql === undefined || sql === null) {
        handleError("Invalid parameter 'sql'.", callback);
        return;
    }

    this.connection = connection;
    this.sql = sql;
    this.stmt = null;
    this.result = 0;

    if (callback) {
        var hanaStmt = this;
        this.connection.prepare(sql, function (err, stmt) {
            hanaStmt.stmt = stmt;
            callback(err, hanaStmt);
        });
    } else {
        this.stmt = this.connection.prepare(sql);
    }
};

HanaStatement.prototype.getStatement = function () {
    return this.stmt;
};

HanaStatement.prototype.drop = function (callback) {
    if (this.stmt) {
        var stmt = this.stmt;
        this.stmt = null;
        stmt.drop(callback);
    }
};

HanaStatement.prototype.exec = function (params, callback) {
    if (callback === undefined || callback === null || typeof callback !== 'function') {
        throw new Error("Invalid parameter 'callback'.");
    }
    if (params === undefined || params === null || !(params instanceof Array)) {
        handleError("Invalid parameter 'params'.", callback);
        return;
    }
    if (!this.stmt) {
        handleError("Invalid statement.", callback);
        return;
    }

    var hasArrayParam = false;
    var hasNonArrayParam = false;

    for (var i = 0; i < params.length; i++) {
        if (params[i] instanceof Array) {
            hasArrayParam = true;
        } else {
            hasNonArrayParam = true;
        }
        if (hasArrayParam && hasNonArrayParam) {
            handleError("Invalid parameter 'params': contains both array and non-array parameters.", callback);
            return;
        }
    }

    if (params.length === 0) {
        callback(null, 0);
        return;
    }

    this.callback = callback;
    this.currentRow = 0;

    if (hasArrayParam) {
        this.paramsArray = params;
    } else {
        this.paramsArray = [];
        this.paramsArray.push(params);
    }

    this._exec();
}

HanaStatement.prototype._exec = function () {
    this.params = this.paramsArray[this.currentRow];

    var isStream = [];
    var paramsNew = [];
    var streamCount = 0;
    var hanaStmt = this;

    for (var i = 0; i < this.params.length; i++) {
        if (this.params[i] instanceof Stream) {
            this.params[i].pause();
            isStream.push(true);
            paramsNew.push({sendParameterData : true});
            streamCount++;
        } else {
            isStream.push(false);
            paramsNew.push(this.params[i]);
        }
    }

    if (streamCount <= 0) {
        this.stmt.exec(paramsNew, function (err, result) {
            if (err) {
                hanaStmt.callback(err, result);
            } else {
                hanaStmt.currentRow++;
                hanaStmt.result += result;
                if (hanaStmt.currentRow === hanaStmt.paramsArray.length) {
                    hanaStmt.callback(null, hanaStmt.result);
                } else {
                    hanaStmt._exec();
                }
            }
        });
    } else {
        this.stmt.exec(paramsNew, function (err, result) {
            if (err) {
                hanaStmt.callback(err, result);
            } else {
                for (var paramIndex = 0; paramIndex < hanaStmt.params.length; paramIndex++) {
                    if (hanaStmt.params[paramIndex] instanceof Stream) {
                        break;
                    }
                }
                hanaStmt.sendParameterData(paramIndex);
            }
        });
    }
};

HanaStatement.prototype.sendParameterData = function (paramIndex) {
    var hanaStmt = this;

    this.params[paramIndex].on('error', function (error) {
        hanaStmt.callback(error);
    });

    this.params[paramIndex].on('end', function () {
        hanaStmt.stmt.sendParameterData(paramIndex, null, function (err) {
            if (err) {
                hanaStmt.callback(err);
            } else {
                var nextStream = -1;
                for (var i = paramIndex + 1; i < hanaStmt.params.length; i++) {
                    if (hanaStmt.params[i] instanceof Stream) {
                        nextStream = i;
                        break;
                    }
                }
                if (nextStream >= 0) {
                    hanaStmt.sendParameterData(nextStream);
                } else {
                    hanaStmt.currentRow++;
                    hanaStmt.result += 1;
                    if (hanaStmt.currentRow === hanaStmt.paramsArray.length) {
                        hanaStmt.callback(null, hanaStmt.result);
                    } else {
                        hanaStmt._exec();
                    }
                }
            }
        });
    });

    this.params[paramIndex].on('data', function (buffer) {
        hanaStmt.params[paramIndex].pause();
        hanaStmt.stmt.sendParameterData(paramIndex, buffer, function (err) {
            if (err) {
                hanaStmt.callback(err);
            } else {
                hanaStmt.params[paramIndex].resume();
            }
        });
    });

    this.params[paramIndex].resume();
};

// Statement for procedure call
// This method can take two different sets of parameters.
// 1. HanaProcStatement(connection, sql, callback)
// 2. HanaProcStatement(stmt)
function HanaProcStatement(...args) {
    if (args.length !== 1 && args.length !== 3) {
        throw new Error("Invalid number of parameters - HanaProcStatement(connection, sql, callback) or HanaProcStatement(stmt).");
    }

    if (args.length === 1) { // Create a HanaProcStatement using existing Statement
        let stmt = args[0];
        if (stmt === undefined || stmt === null) {
            throw new Error("Invalid parameter 'stmt'.");
        }
        this.stmt = stmt;
        return this;
    }

    var connection = args[0];
    var sql = args[1];
    var callback = args[2];
    if (connection === undefined || connection === null) {
        handleError("Invalid parameter 'connection'.", callback);
        return;
    }
    if (sql === undefined || sql === null) {
        handleError("Invalid parameter 'sql'.", callback);
        return;
    }
    if (callback !== undefined && callback !== null && typeof callback !== 'function') {
        throw new Error("Invalid parameter 'callback'.");
    }

    this.connection = connection;
    this.columnInfo = [];

    if (callback) {
        var hanaStmt = this;
        connection.prepare(sql, function (err, stmt) {
            hanaStmt.stmt = stmt;
            callback(err, hanaStmt);
        });
    } else {
        this.stmt = connection.prepare(sql);
        return this;
    }
};

HanaProcStatement.prototype.drop = function (callback) {
    if (this.stmt) {
        var stmt = this.stmt;
        this.stmt = null;
        stmt.drop(callback);
    }
};

HanaProcStatement.prototype.exec = function (...args /*params, [options,] callback*/) {
    if (args.length != 2 && args.length != 3) {
        throw new Error("Invalid number of parameters - exec(params, [options,] callback).");
    }

    var params = args[0];
    if (args.length == 2) {
        var options = {};
        var callback = args[1];
    } else if (args.length == 3) {
        var options = args[1];
        var callback = args[2];
    }

    if (params !== undefined && params !== null &&
        (!(params instanceof Array) && !(params instanceof Object))) {
        handleError("Invalid parameter 'params'.", callback);
        return;
    }
    if (callback === undefined || callback === null || typeof callback !== 'function') {
        throw new Error("Invalid parameter 'callback'.");
    }
    if (!this.stmt) {
        handleError("Invalid statement.", callback);
        return;
    }

    this.callback = callback;
    this.scalarParams = {};
    this.hasScalarParams = false;
    this.tableParams = [[]];
    this.currentTable = 0;
    this.columnInfo = [];

    var hanaStmt = this;
    this.stmt.execQuery(params, options, function (err, rs) {
        if (err) {
            hanaStmt.callback(err);
        } else {
            hanaStmt.rs = rs;
            var paramInfo = hanaStmt.stmt.getParameterInfo();
            var paramCount = 0;
            for (var i = 0; i < paramInfo.length; i++) {
                if (paramInfo[i].direction === 2 || paramInfo[i].direction === 3) {
                    hanaStmt.hasScalarParams = true;
                    hanaStmt.stmt.getParameterValue(i, (function() {
                        var index = i;
                        return function(err, val) {
                            if(err) {
                                hanaStmt.callback(err);
                            } else {
                                ++paramCount;
                                hanaStmt.scalarParams[paramInfo[index].name] = val;
                                if (paramCount === paramInfo.length) {
                                    return hanaStmt.fetchTableParam();
                                }
                            }
                        };
                    })() );
                } else {
                    ++paramCount;
                }
            }
            if (paramCount === paramInfo.length) {
                hanaStmt.fetchTableParam();
            }
        }
    });
}

// Returns an array of arrays. Each array contains column info for each output table.
HanaProcStatement.prototype.getColumnInfo = function () {
    return this.columnInfo;
};

HanaProcStatement.prototype.execute = function (params, callback) {
    this.exec(params, callback);
}

HanaProcStatement.prototype.fetchTableParam = function () {
    function fetchNextResult(hanaStmt) {
        var columns = hanaStmt.rs.getColumnInfo();
        if ((columns != null) && (columns != undefined) && (columns.length > 0)) {
            hanaStmt.columnInfo.push(columns);
        }
        hanaStmt.rs.nextResult(function (err, hasResult) {
            if (err) {
                hanaStmt.callback(err);
            } else if (hasResult) {
                hanaStmt.currentTable++;
                hanaStmt.tableParams.push([]);
                hanaStmt.fetchTableParam();
            } else {
                hanaStmt.rs.close(function (err) {
                    if (err) {
                        hanaStmt.callback(err);
                    } else {
                        var args = [];
                        if (hanaStmt.hasScalarParams) {
                            args.push(hanaStmt.scalarParams);
                        } else {
                            args.push({});
                        }
                        for (var i = 0; i < hanaStmt.tableParams.length; i++) {
                            args.push(hanaStmt.tableParams[i]);
                        }
                        var evalStr = 'hanaStmt.callback(null';
                        for (var j = 0; j < args.length; j++) {
                            evalStr += ', args[' + j + ']';
                        }
                        evalStr += ')';
                        eval(evalStr);
                    }
                });
            }
        });
    }

    var hanaStmt = this;
    if (this.rs.nextCanBlock()) {
        // async next
        this.rs.next(function (err, hasData) {
            if (err) {
                hanaStmt.callback(err);
            } else {
                if (hasData) {
                    try {
                        hanaStmt.tableParams[hanaStmt.currentTable].push(hanaStmt.rs.getValues());
                    } catch (newerr) {
                        hanaStmt.callback(newerr);
                        return;
                    }
                    hanaStmt.fetchTableParam();
                } else {
                    fetchNextResult(hanaStmt);
                }
            }
        });
    } else {
        // nextCanBlock returned false, use sync next.
        var hasDataSync = false;
        try {
            hasDataSync = this.rs.next();
            if (hasDataSync) {
                hanaStmt.tableParams[hanaStmt.currentTable].push(hanaStmt.rs.getValues());
            }
        } catch (newerr) {
            hanaStmt.callback(newerr);
            return;
        }
        if (hasDataSync) {
            hanaStmt.fetchTableParam();
        } else {
            fetchNextResult(hanaStmt);
        }
    }
}

function handleError(err, callback) {
    if (callback) {
        callback(err);
    } else {
        throw err;
    }
};

function isLob(type) {
    if (type === 25 || // CLOB
        type === 26 || // NCLOB
        type === 27 || // BLOB
        type === 13) { // VARBINARY
        return true;
    }
    return false;
};

function checkParameter(name, param) {
    if (param === undefined || param === null) {
        throw new Error("Invalid parameter '" + name + "'.");
    }
};

function checkColumnIndex(columnInfo, columnIndex) {
    if (columnIndex === undefined || columnIndex === null ||
        columnIndex < 0 || columnIndex >= columnInfo.length) {
        throw new Error("Invalid parameter 'columnIndex'.");
    }
};

function checkLobType(columnInfo, columnIndex) {
    var type = columnInfo[columnIndex].nativeType;
    if (!isLob(type)) {
        throw new Error('Column is not LOB type.');
    }
};

function checkParameterIndex(paramInfo, paramIndex) {
    if (paramIndex === undefined || paramIndex === null ||
        paramIndex < 0 || paramIndex >= paramInfo.length) {
        throw new Error("Invalid parameter 'paramIndex'.");
    }
};

function checkParameterLobType(paramInfo, paramIndex) {
    var type = paramInfo[paramIndex].nativeType;
    if (!isLob(type)) {
        throw new Error('Parameter is not LOB type.');
    }
};

function createBuffer(size) {
    if (typeof Buffer.alloc === 'function') {
        return Buffer.alloc(size);
    } else {
        return new Buffer(size);
    }
};

function isInteger(value) {
    if (typeof value === 'number' && value % 1 === 0) {
        return true;
    } else {
        return false;
    }
}

function getLobStreamOptions(options) {
    var opts = {};
    Object.assign(opts, options);
    opts.objectMode = false;
    if (isInteger(opts.readSize) && opts.readSize > 0) {
        opts.highWaterMark = opts.readSize;
    }
    opts.noPause = false;
    if (options && 'noPause' in options && (typeof options.noPause === 'boolean')) {
        opts.noPause = options.noPause;
    }
    return opts;
}

var MAX_READ_SIZE = Math.pow(2, 18);
var DEFAULT_READ_SIZE = Math.pow(2, 11) * 100;
