'use strict';

const Command = require('./command');
const ServerStatus = require('../const/server-status');
const ColumnDefinition = require('./column-definition');
const Errors = require('../misc/errors');
const fs = require('fs');
const Parse = require('../misc/parse');

/**
 * handle COM_QUERY / COM_STMT_EXECUTE results
 * see : https://mariadb.com/kb/en/library/4-server-response-packets/
 */
class ResultSet extends Command {
  constructor(resolve, reject) {
    super(resolve, reject);
    this._responseIndex = 0;
    this._rows = [];
  }

  /**
   * Read Query response packet.
   * packet can be :
   * - a result-set
   * - an ERR_Packet
   * - a OK_Packet
   * - LOCAL_INFILE Packet
   *
   * @param packet  query response
   * @param out     output writer
   * @param opts    connection options
   * @param info    connection info
   */
  readResponsePacket(packet, out, opts, info) {
    switch (packet.peek()) {
      //*********************************************************************************************************
      //* OK response
      //*********************************************************************************************************
      case 0x00:
        return this.readOKPacket(packet, out, opts, info);

      //*********************************************************************************************************
      //* ERROR response
      //*********************************************************************************************************
      case 0xff:
        const err = packet.readError(info, this.displaySql(), this.stack);
        //force in transaction status, since query will have created a transaction if autocommit is off
        //goal is to avoid unnecessary COMMIT/ROLLBACK.
        info.status |= ServerStatus.STATUS_IN_TRANS;
        return this.throwError(err, info);

      //*********************************************************************************************************
      //* LOCAL INFILE response
      //*********************************************************************************************************
      case 0xfb:
        return this.readLocalInfile(packet, out, opts, info);

      //*********************************************************************************************************
      //* ResultSet
      //*********************************************************************************************************
      default:
        return this.readResultSet(packet);
    }
  }

  /**
   * Read result-set packets :
   * see https://mariadb.com/kb/en/library/resultset/
   *
   * @param packet    Column count packet
   * @returns {ResultSet.readColumn} next packet handler
   */
  readResultSet(packet) {
    this._columnCount = packet.readUnsignedLength();
    this._getValue = this.opts.typeCast ? this.readCastValue : this.readRowData;
    this._rows.push([]);
    this._columns = [];

    this.onPacketReceive = this.readColumn;
  }

  /**
   * Assign global configuration option used by result-set to current query option.
   * a little faster than Object.assign() since doest copy all information
   *
   * @param connOpts  connection global configuration
   * @param cmdOpts   specific command options
   */
  configAssign(connOpts, cmdOpts) {
    if (!cmdOpts) {
      this.opts = connOpts;
      return;
    }
    this.opts = {
      timeout: cmdOpts.timeout,
      autoJsonMap: connOpts.autoJsonMap,
      arrayParenthesis: connOpts.arrayParenthesis,
      supportBigInt:
        cmdOpts.supportBigInt != undefined ? cmdOpts.supportBigInt : connOpts.supportBigInt,
      checkDuplicate:
        cmdOpts.checkDuplicate != undefined ? cmdOpts.checkDuplicate : connOpts.checkDuplicate,
      typeCast: cmdOpts.typeCast != undefined ? cmdOpts.typeCast : connOpts.typeCast,
      rowsAsArray: cmdOpts.rowsAsArray != undefined ? cmdOpts.rowsAsArray : connOpts.rowsAsArray,
      nestTables: cmdOpts.nestTables != undefined ? cmdOpts.nestTables : connOpts.nestTables,
      dateStrings: cmdOpts.dateStrings != undefined ? cmdOpts.dateStrings : connOpts.dateStrings,
      tz: cmdOpts.tz != undefined ? cmdOpts.tz : connOpts.tz,
      pipelining: connOpts.pipelining,
      _localTz: cmdOpts.localTz != undefined ? cmdOpts.localTz : connOpts._localTz,
      namedPlaceholders:
        cmdOpts.namedPlaceholders != undefined
          ? cmdOpts.namedPlaceholders
          : connOpts.namedPlaceholders,
      maxAllowedPacket:
        cmdOpts.maxAllowedPacket != undefined
          ? cmdOpts.maxAllowedPacket
          : connOpts.maxAllowedPacket,
      supportBigNumbers:
        cmdOpts.supportBigNumbers != undefined
          ? cmdOpts.supportBigNumbers
          : connOpts.supportBigNumbers,
      permitSetMultiParamEntries:
        cmdOpts.permitSetMultiParamEntries != undefined
          ? cmdOpts.permitSetMultiParamEntries
          : connOpts.permitSetMultiParamEntries,
      bigNumberStrings:
        cmdOpts.bigNumberStrings != undefined ? cmdOpts.bigNumberStrings : connOpts.bigNumberStrings
    };
  }

  /**
   * Read OK_Packet.
   * see https://mariadb.com/kb/en/library/ok_packet/
   *
   * @param packet    OK_Packet
   * @param opts      connection options
   * @param info      connection information
   * @param out       output writer
   * @returns {*}     null or {Resultset.readResponsePacket} in case of multi-result-set
   */
  readOKPacket(packet, out, opts, info) {
    const okPacket = Command.parseOkPacket(packet, out, opts, info);
    this._rows.push(okPacket);

    if (info.status & ServerStatus.MORE_RESULTS_EXISTS) {
      this._responseIndex++;
      return (this.onPacketReceive = this.readResponsePacket);
    }
    this.success(this._responseIndex === 0 ? this._rows[0] : this._rows);
  }

  /**
   * Read COM_STMT_PREPARE response Packet.
   * see https://mariadb.com/kb/en/library/com_stmt_prepare/#com_stmt_prepare-response
   *
   * @param packet    COM_STMT_PREPARE_OK packet
   * @param opts      connection options
   * @param info      connection information
   * @param out       output writer
   * @returns {*}     null or {Resultset.readResponsePacket} in case of multi-result-set
   */
  readPrepareResultPacket(packet, out, opts, info) {
    switch (packet.peek()) {
      //*********************************************************************************************************
      //* OK response
      //*********************************************************************************************************
      case 0x00:
        packet.skip(1); //skip header
        this.statementId = packet.readInt32();
        this.columnNo = packet.readUInt16();
        this.parameterNo = packet.readUInt16();

        if (this.columnNo > 0) return (this.onPacketReceive = this.skipColumnsPacket);
        if (this.parameterNo > 0) return (this.onPacketReceive = this.skipParameterPacket);
        return this.success();

      //*********************************************************************************************************
      //* ERROR response
      //*********************************************************************************************************
      case 0xff:
        const err = packet.readError(info, this.displaySql(), this.stack);
        //force in transaction status, since query will have created a transaction if autocommit is off
        //goal is to avoid unnecessary COMMIT/ROLLBACK.
        info.status |= ServerStatus.STATUS_IN_TRANS;
        this.onPacketReceive = this.readResponsePacket;
        return this.throwError(err, info);

      //*********************************************************************************************************
      //* Unexpected response
      //*********************************************************************************************************
      default:
        info.status |= ServerStatus.STATUS_IN_TRANS;
        this.onPacketReceive = this.readResponsePacket;
        return this.throwError(Errors.ER_UNEXPECTED_PACKET, info);
    }
  }

  skipColumnsPacket(packet, out, opts, info) {
    this.columnNo--;
    if (this.columnNo === 0) {
      if (info.eofDeprecated) {
        if (this.parameterNo > 0) return (this.onPacketReceive = this.skipParameterPacket);
        this.success();
      }
      return (this.onPacketReceive = this.skipEofPacket);
    }
  }

  skipEofPacket(packet, out, opts, info) {
    if (this.parameterNo > 0) return (this.onPacketReceive = this.skipParameterPacket);
    this.success();
  }

  skipParameterPacket(packet, out, opts, info) {
    this.parameterNo--;
    if (this.parameterNo === 0) {
      if (info.eofDeprecated) return this.success();
      return (this.onPacketReceive = this.skipEofPacket);
    }
  }

  success(val) {
    this.successEnd(val);
    this._columns = null;
    this._rows = null;
  }

  /**
   * Read column information metadata
   * see https://mariadb.com/kb/en/library/resultset/#column-definition-packet
   *
   * @param packet    column definition packet
   * @param out       output writer
   * @param opts      connection options
   * @param info      connection information
   * @returns {*}
   */
  readColumn(packet, out, opts, info) {
    if (this._columns.length !== this._columnCount) {
      this._columns.push(new ColumnDefinition(packet, info));
    }

    // last column
    if (this._columns.length === this._columnCount) {
      if (this.opts.rowsAsArray) {
        this.parseRow = this.parseRowAsArray;
      } else {
        this.tableHeader = new Array(this._columnCount);
        if (this.opts.nestTables) {
          this.parseRow = this.parseRowStd;
          if (typeof this.opts.nestTables === 'string') {
            for (let i = 0; i < this._columnCount; i++) {
              this.tableHeader[i] =
                this._columns[i].table() + this.opts.nestTables + this._columns[i].name();
            }
            this.checkDuplicates();
          } else if (this.opts.nestTables === true) {
            this.parseRow = this.parseRowNested;
            for (let i = 0; i < this._columnCount; i++) {
              this.tableHeader[i] = [this._columns[i].table(), this._columns[i].name()];
            }
            this.checkNestTablesDuplicates();
          }
        } else {
          this.parseRow = this.parseRowStd;
          for (let i = 0; i < this._columnCount; i++) {
            this.tableHeader[i] = this._columns[i].name();
          }
          this.checkDuplicates();
        }
      }

      this.emit('fields', this._columns);

      return (this.onPacketReceive = info.eofDeprecated
        ? this.readResultSetRow
        : this.readIntermediateEOF);
    }
  }

  checkDuplicates() {
    if (this.opts.checkDuplicate) {
      for (let i = 0; i < this._columnCount; i++) {
        if (this.tableHeader.indexOf(this.tableHeader[i], i + 1) > 0) {
          const dupes = this.tableHeader.reduce(
            (acc, v, i, arr) =>
              arr.indexOf(v) !== i && acc.indexOf(v) === -1 ? acc.concat(v) : acc,
            []
          );
          this.throwUnexpectedError(
            'Error in results, duplicate field name `' +
              dupes[0] +
              '`.\n' +
              '(see option `checkDuplicate`)',
            false,
            null,
            '42000',
            Errors.ER_DUPLICATE_FIELD
          );
        }
      }
    }
  }

  checkNestTablesDuplicates() {
    if (this.opts.checkDuplicate) {
      for (let i = 0; i < this._columnCount; i++) {
        for (let j = 0; j < i; j++) {
          if (
            this.tableHeader[j][0] === this.tableHeader[i][0] &&
            this.tableHeader[j][1] === this.tableHeader[i][1]
          ) {
            this.throwUnexpectedError(
              'Error in results, duplicate field name `' +
                this.tableHeader[i][0] +
                '`.`' +
                this.tableHeader[i][1] +
                '`\n' +
                '(see option `checkDuplicate`)',
              false,
              null,
              '42000',
              Errors.ER_DUPLICATE_FIELD
            );
          }
        }
      }
    }
  }

  /**
   * Read intermediate EOF.
   * _only for server before MariaDB 10.2 / MySQL 5.7 that doesn't have CLIENT_DEPRECATE_EOF capability_
   * see https://mariadb.com/kb/en/library/eof_packet/
   *
   * @param packet    EOF Packet
   * @param out       output writer
   * @param opts      connection options
   * @param info      connection information
   * @returns {*}
   */
  readIntermediateEOF(packet, out, opts, info) {
    if (packet.peek() !== 0xfe) {
      return this.throwNewError(
        'Error in protocol, expected EOF packet',
        true,
        info,
        '42000',
        Errors.ER_EOF_EXPECTED
      );
    }

    //before MySQL 5.7.5, last EOF doesn't contain the good flag SERVER_MORE_RESULTS_EXISTS
    //for OUT parameters. It must be checked here
    //(5.7.5 does have the CLIENT_DEPRECATE_EOF capability, so this packet in not even send)
    packet.skip(3);
    info.status = packet.readUInt16();
    this.isOutParameter = info.status & ServerStatus.PS_OUT_PARAMS;

    this.onPacketReceive = this.readResultSetRow;
  }

  handleNewRows(row) {
    this._rows[this._responseIndex].push(row);
  }

  /**
   * Check if packet is result-set end = EOF of OK_Packet with EOF header according to CLIENT_DEPRECATE_EOF capability
   * or a result-set row
   *
   * @param packet    current packet
   * @param out       output writer
   * @param opts      connection options
   * @param info      connection information
   * @returns {*}
   */
  readResultSetRow(packet, out, opts, info) {
    if (packet.peek() >= 0xfe) {
      if (packet.peek() === 0xff) {
        const err = packet.readError(info, this.displaySql(), this.stack);
        //force in transaction status, since query will have created a transaction if autocommit is off
        //goal is to avoid unnecessary COMMIT/ROLLBACK.
        info.status |= ServerStatus.STATUS_IN_TRANS;
        return this.throwError(err, info);
      }

      if (
        (!info.eofDeprecated && packet.length() < 13) ||
        (info.eofDeprecated && packet.length() < 0xffffff)
      ) {
        if (!info.eofDeprecated) {
          packet.skip(3);
          info.status = packet.readUInt16();
        } else {
          packet.skip(1); //skip header
          packet.skipLengthCodedNumber(); //skip update count
          packet.skipLengthCodedNumber(); //skip insert id
          info.status = packet.readUInt16();
        }

        if (opts.metaAsArray) {
          //return promise object as array :
          // example for SELECT 1 =>
          // [
          //   [ {"1": 1} ],      //rows
          //   [ColumnDefinition] //meta
          // ]
          if (!this._meta) {
            this._meta = new Array(this._responseIndex);
          }

          this._meta[this._responseIndex] = this._columns;

          if (info.status & ServerStatus.MORE_RESULTS_EXISTS || this.isOutParameter) {
            this._responseIndex++;
            return (this.onPacketReceive = this.readResponsePacket);
          }
          this.success(
            this._responseIndex === 0 ? [this._rows[0], this._meta[0]] : [this._rows, this._meta]
          );
        } else {
          //return promise object as rows that have meta property :
          // example for SELECT 1 =>
          // [
          //   {"1": 1},
          //   meta: [ColumnDefinition]
          // ]
          this._rows[this._responseIndex].meta = this._columns;
          if (info.status & ServerStatus.MORE_RESULTS_EXISTS || this.isOutParameter) {
            this._responseIndex++;
            return (this.onPacketReceive = this.readResponsePacket);
          }
          this.success(this._responseIndex === 0 ? this._rows[0] : this._rows);
        }

        return;
      }
    }

    const row = this.parseRow(this._columns, packet, opts);
    this.handleNewRows(row);
  }

  /**
   * Display current SQL with parameters (truncated if too big)
   *
   * @returns {string}
   */
  displaySql() {
    if (this.opts && this.initialValues) {
      if (this.sql.length > this.opts.debugLen) {
        return this.sql.substring(0, this.opts.debugLen) + '...';
      }

      let sqlMsg = this.sql + ' - parameters:';
      return this.logParameters(sqlMsg, this.initialValues);
    }
    return this.sql + ' - parameters:[]';
  }

  logParameters(sqlMsg, values) {
    if (this.opts.namedPlaceholders) {
      sqlMsg += '{';
      let first = true;
      for (let key in values) {
        if (first) {
          first = false;
        } else {
          sqlMsg += ',';
        }
        sqlMsg += "'" + key + "':";
        let param = values[key];
        sqlMsg = ResultSet.logParam(sqlMsg, param);
        if (sqlMsg.length > this.opts.debugLen) {
          sqlMsg = sqlMsg.substr(0, this.opts.debugLen) + '...';
          break;
        }
      }
      sqlMsg += '}';
    } else {
      sqlMsg += '[';
      if (Array.isArray(values)) {
        for (let i = 0; i < values.length; i++) {
          if (i !== 0) sqlMsg += ',';
          let param = values[i];
          sqlMsg = ResultSet.logParam(sqlMsg, param);
          if (sqlMsg.length > this.opts.debugLen) {
            sqlMsg = sqlMsg.substr(0, this.opts.debugLen) + '...';
            break;
          }
        }
      } else {
        sqlMsg = ResultSet.logParam(sqlMsg, values);
        if (sqlMsg.length > this.opts.debugLen) {
          sqlMsg = sqlMsg.substr(0, this.opts.debugLen) + '...';
        }
      }
      sqlMsg += ']';
    }
    return sqlMsg;
  }

  readLocalInfile(packet, out, opts, info) {
    packet.skip(1); //skip header
    out.startPacket(this);

    const fileName = packet.readStringRemaining();

    if (!Parse.validateFileName(this.sql, this.initialValues, fileName)) {
      out.writeEmptyPacket();
      const error = Errors.createError(
        "LOCAL INFILE wrong filename. '" +
          fileName +
          "' doesn't correspond to query " +
          this.sql +
          '. Query cancelled. Check for malicious server / proxy',
        this.sql,
        false,
        info,
        '45034',
        Errors.ER_LOCAL_INFILE_WRONG_FILENAME
      );

      process.nextTick(this.reject, error);
      this.reject = null;
      this.resolve = null;
      return (this.onPacketReceive = this.readResponsePacket);
    }

    // this.sequenceNo = 2;
    // this.compressSequenceNo = 2;
    const stream = fs.createReadStream(fileName);
    stream.on('error', (err) => {
      out.writeEmptyPacket();
      const error = Errors.createError(
        'LOCAL INFILE command failed: ' + err.message,
        this.sql,
        false,
        info,
        '22000',
        Errors.ER_LOCAL_INFILE_NOT_READABLE
      );
      process.nextTick(this.reject, error);
      this.reject = null;
      this.resolve = null;
    });
    stream.on('data', (chunk) => {
      out.writeBuffer(chunk, 0, chunk.length);
    });
    stream.on('end', () => {
      if (!out.isEmpty()) {
        out.flushBuffer(false);
      }
      out.writeEmptyPacket();
    });
    this.onPacketReceive = this.readResponsePacket;
  }

  static logParam(sqlMsg, param) {
    if (param === undefined || param === null) {
      sqlMsg += param === undefined ? 'undefined' : 'null';
    } else {
      switch (param.constructor.name) {
        case 'Buffer':
          sqlMsg += '0x' + param.toString('hex', 0, Math.min(1024, param.length)) + '';
          break;

        case 'String':
          sqlMsg += "'" + param + "'";
          break;

        case 'Date':
          sqlMsg += getStringDate(param);
          break;

        case 'Object':
          sqlMsg += JSON.stringify(param);
          break;

        default:
          sqlMsg += param.toString();
      }
    }
    return sqlMsg;
  }
}

function getStringDate(param) {
  return (
    "'" +
    ('00' + (param.getMonth() + 1)).slice(-2) +
    '/' +
    ('00' + param.getDate()).slice(-2) +
    '/' +
    param.getFullYear() +
    ' ' +
    ('00' + param.getHours()).slice(-2) +
    ':' +
    ('00' + param.getMinutes()).slice(-2) +
    ':' +
    ('00' + param.getSeconds()).slice(-2) +
    '.' +
    ('000' + param.getMilliseconds()).slice(-3) +
    "'"
  );
}

module.exports = ResultSet;
