/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

'use strict';

const vm = require('vm');
const qModule = require('./q');
const QueryOperator = qModule.QueryOperator;
const QueryAssignment = qModule.QueryAssignment;
const types = require('../types');
const dataTypes = types.dataTypes;

const vmFileName = 'gen-param-getter.js';

/**
 * Provides methods to generate a query and parameter handlers.
 * @ignore
 */
class QueryGenerator {
  /**
   * Gets the SELECT query given the doc.
   * @param {String} tableName
   * @param {String} keyspace
   * @param {Array} propertiesInfo
   * @param {Array} fieldsInfo
   * @param {Array} orderByColumns
   * @param {Number|undefined} limit
   * @return {string}
   */
  static getSelect(tableName, keyspace, propertiesInfo, fieldsInfo, orderByColumns, limit) {
    let query = 'SELECT ';
    query += fieldsInfo.length > 0 ? fieldsInfo.map(p => `"${p.columnName}"`).join(', ') : '*';
    query += ` FROM ${keyspace}.${tableName}`;

    if (propertiesInfo.length > 0) {
      query += ' WHERE ';
      query += QueryGenerator._getConditionWithOperators(propertiesInfo);
    }

    if (orderByColumns.length > 0) {
      query += ' ORDER BY ';
      query += orderByColumns.map(order => `"${order[0]}" ${order[1]}`).join(', ');
    }

    if (typeof limit === 'number') {
      query += ' LIMIT ?';
    }

    return query;
  }

  static selectParamsGetter(propertiesInfo, limit) {
    let scriptText = '(function getParametersSelect(doc, docInfo, mappingInfo) {\n';
    scriptText += '  return [';

    scriptText += QueryGenerator._valueGetterExpression(propertiesInfo);

    if (typeof limit === 'number') {
      if (propertiesInfo.length > 0) {
        scriptText += ', ';
      }
      scriptText += `docInfo['limit']`;
    }

    // Finish return statement
    scriptText += '];\n})';

    const script = new vm.Script(scriptText, { filename: vmFileName });
    return script.runInThisContext();
  }

  /**
   * Gets the INSERT query and function to obtain the parameters, given the doc.
   * @param {TableMetadata} table
   * @param {String} keyspace
   * @param {Array} propertiesInfo
   * @param {Object} docInfo
   * @param {Boolean|undefined} ifNotExists
   * @return {{query: String, paramsGetter: Function, isIdempotent: Boolean}}
   */
  static getInsert(table, keyspace, propertiesInfo, docInfo, ifNotExists) {
    const ttl = docInfo && docInfo.ttl;

    // Not all columns are contained in the table
    const filteredPropertiesInfo = propertiesInfo
      .filter(pInfo => table.columnsByName[pInfo.columnName] !== undefined);

    return ({
      query: QueryGenerator._getInsertQuery(table.name, keyspace, filteredPropertiesInfo, ifNotExists, ttl),
      paramsGetter: QueryGenerator._insertParamsGetter(filteredPropertiesInfo, docInfo),
      isIdempotent: !ifNotExists
    });
  }

  /**
   * Gets the query for an insert statement.
   * @param {String} tableName
   * @param {String} keyspace
   * @param {Array} propertiesInfo
   * @param {Boolean} ifNotExists
   * @param {Number|undefined} ttl
   * @return {String}
   */
  static _getInsertQuery(tableName, keyspace, propertiesInfo, ifNotExists, ttl) {
    let query = `INSERT INTO ${keyspace}.${tableName} (`;
    query += propertiesInfo.map(pInfo => `"${pInfo.columnName}"`).join(', ');
    query += ') VALUES (';
    query += propertiesInfo.map(() => '?').join(', ');
    query += ')';

    if (ifNotExists === true) {
      query += ' IF NOT EXISTS';
    }

    if (typeof ttl === 'number') {
      query += ' USING TTL ?';
    }
    return query;
  }

  static _insertParamsGetter(propertiesInfo, docInfo) {
    let scriptText = '(function getParametersInsert(doc, docInfo, mappingInfo) {\n';
    scriptText += '  return [';

    scriptText += QueryGenerator._valueGetterExpression(propertiesInfo);

    if (docInfo && typeof docInfo.ttl === 'number') {
      scriptText += `, docInfo['ttl']`;
    }

    // Finish return statement
    scriptText += '];\n})';

    const script = new vm.Script(scriptText, { filename: vmFileName });
    return script.runInThisContext();
  }

  /**
   * Gets the UPDATE query and function to obtain the parameters, given the doc.
   * @param {TableMetadata} table
   * @param {String} keyspace
   * @param {Array} propertiesInfo
   * @param {Object} docInfo
   * @param {Array} when
   * @param {Boolean|undefined} ifExists
   * @return {{query: String, paramsGetter: Function, isIdempotent: Boolean, isCounter}}
   */
  static getUpdate(table, keyspace, propertiesInfo, docInfo, when, ifExists) {
    const ttl = docInfo && docInfo.ttl;
    const primaryKeys = new Set(table.partitionKeys.concat(table.clusteringKeys).map(c => c.name));
    let isIdempotent = true;
    let isCounter = false;

    // Not all columns are contained in the table
    const filteredPropertiesInfo = propertiesInfo.filter(pInfo => {
      const column = table.columnsByName[pInfo.columnName];
      if (column === undefined) {
        return false;
      }

      if (column.type.code === dataTypes.list && pInfo.value instanceof QueryAssignment) {
        // Its not idempotent when list append/prepend
        isIdempotent = false;
      } else if (column.type.code === dataTypes.counter) {
        // Any update on a counter table is not idempotent
        isIdempotent = false;
        isCounter = true;
      }

      return true;
    });

    return {
      query: QueryGenerator._getUpdateQuery(
        table.name, keyspace, primaryKeys, filteredPropertiesInfo, when, ifExists, ttl),
      isIdempotent: isIdempotent && when.length === 0 && !ifExists,
      paramsGetter: QueryGenerator._updateParamsGetter(primaryKeys, filteredPropertiesInfo, when, ttl),
      isCounter
    };
  }

  /**
   * Gets the query for an UPDATE statement.
   * @param {String} tableName
   * @param {String} keyspace
   * @param {Set} primaryKeys
   * @param {Array} propertiesInfo
   * @param {Object} when
   * @param {Boolean} ifExists
   * @param {Number|undefined} ttl
   */
  static _getUpdateQuery(tableName, keyspace, primaryKeys, propertiesInfo, when, ifExists, ttl) {
    let query = `UPDATE ${keyspace}.${tableName} `;

    if (typeof ttl === 'number') {
      query += 'USING TTL ? ';
    }

    query += 'SET ';

    query += propertiesInfo
      .filter(p => !primaryKeys.has(p.columnName))
      .map(p => {
        if (p.value instanceof QueryAssignment) {
          if (p.value.inverted) {
            // e.g: prepend "col1 = ? + col1"
            return `"${p.columnName}" = ? ${p.value.sign} "${p.columnName}"`;
          }
          // e.g: increment "col1 = col1 + ?"
          return `"${p.columnName}" = "${p.columnName}" ${p.value.sign} ?`;
        }

        return `"${p.columnName}" = ?`;
      })
      .join(', ');

    query += ' WHERE ';
    query += propertiesInfo.filter(p => primaryKeys.has(p.columnName)).map(p => `"${p.columnName}" = ?`).join(' AND ');

    if (ifExists === true) {
      query += ' IF EXISTS';
    }
    else if (when.length > 0) {
      query += ' IF ' + QueryGenerator._getConditionWithOperators(when);
    }

    return query;
  }

  /**
   * Returns a function to obtain the parameter values from a doc for an UPDATE statement.
   * @param {Set} primaryKeys
   * @param {Array} propertiesInfo
   * @param {Array} when
   * @param {Number|undefined} ttl
   * @returns {Function}
   */
  static _updateParamsGetter(primaryKeys, propertiesInfo, when, ttl) {
    let scriptText = '(function getParametersUpdate(doc, docInfo, mappingInfo) {\n';
    scriptText += '  return [';

    if (typeof ttl === 'number') {
      scriptText += `docInfo['ttl'], `;
    }

    // Assignment clause
    scriptText += QueryGenerator._assignmentGetterExpression(propertiesInfo.filter(p => !primaryKeys.has(p.columnName)));
    scriptText += ', ';

    // Where clause
    scriptText += QueryGenerator._valueGetterExpression(propertiesInfo.filter(p => primaryKeys.has(p.columnName)));

    // Condition clause
    if (when.length > 0) {
      scriptText += ', ' + QueryGenerator._valueGetterExpression(when, 'docInfo.when');
    }

    // Finish return statement
    scriptText += '];\n})';

    const script = new vm.Script(scriptText, { filename: vmFileName });
    return script.runInThisContext();
  }

  /**
   * Gets the DELETE query and function to obtain the parameters, given the doc.
   * @param {TableMetadata} table
   * @param {String} keyspace
   * @param {Array} propertiesInfo
   * @param {Object} docInfo
   * @param {Array} when
   * @param {Boolean|undefined} ifExists
   * @return {{query: String, paramsGetter: Function, isIdempotent}}
   */
  static getDelete(table, keyspace, propertiesInfo, docInfo, when, ifExists) {
    const deleteOnlyColumns = docInfo && docInfo.deleteOnlyColumns;
    const primaryKeys = new Set(table.partitionKeys.concat(table.clusteringKeys).map(c => c.name));

    const filteredPropertiesInfo = propertiesInfo
      .filter(pInfo => table.columnsByName[pInfo.columnName] !== undefined);


    return ({
      query: QueryGenerator._getDeleteQuery(
        table.name, keyspace, primaryKeys, filteredPropertiesInfo, when, ifExists, deleteOnlyColumns),
      paramsGetter: QueryGenerator._deleteParamsGetter(primaryKeys, filteredPropertiesInfo, when),
      isIdempotent: when.length === 0 && !ifExists
    });
  }

  /**
   * Gets the query for an UPDATE statement.
   * @param {String} tableName
   * @param {String} keyspace
   * @param {Set} primaryKeys
   * @param {Array} propertiesInfo
   * @param {Array} when
   * @param {Boolean} ifExists
   * @param {Boolean} deleteOnlyColumns
   * @private
   * @return {String}
   */
  static _getDeleteQuery(tableName, keyspace, primaryKeys, propertiesInfo, when, ifExists, deleteOnlyColumns) {
    let query = 'DELETE';

    if (deleteOnlyColumns) {
      const columnsToDelete = propertiesInfo.filter(p => !primaryKeys.has(p.columnName))
        .map(p => `"${p.columnName}"`)
        .join(', ');

      if (columnsToDelete !== '') {
        query += ' ' + columnsToDelete;
      }
    }

    query += ` FROM ${keyspace}.${tableName} WHERE `;
    query += propertiesInfo.filter(p => primaryKeys.has(p.columnName)).map(p => `"${p.columnName}" = ?`).join(' AND ');

    if (ifExists === true) {
      query += ' IF EXISTS';
    }
    else if (when.length > 0) {
      query += ' IF ' + QueryGenerator._getConditionWithOperators(when);
    }

    return query;
  }
  /**
   * Returns a function to obtain the parameter values from a doc for an UPDATE statement.
   * @param {Set} primaryKeys
   * @param {Array} propertiesInfo
   * @param {Array} when
   * @returns {Function}
   */
  static _deleteParamsGetter(primaryKeys, propertiesInfo, when) {
    let scriptText = '(function getParametersDelete(doc, docInfo, mappingInfo) {\n';
    scriptText += '  return [';

    // Where clause
    scriptText += QueryGenerator._valueGetterExpression(propertiesInfo.filter(p => primaryKeys.has(p.columnName)));

    // Condition clause
    if (when.length > 0) {
      scriptText += ', ' + QueryGenerator._valueGetterExpression(when, 'docInfo.when');
    }

    // Finish return statement
    scriptText += '];\n})';

    const script = new vm.Script(scriptText, { filename: vmFileName });
    return script.runInThisContext();
  }

  /**
   * Gets a string containing the doc properties to get.
   * @param {Array} propertiesInfo
   * @param {String} [objectName='doc']
   * @return {string}
   * @private
   */
  static _valueGetterExpression(propertiesInfo, objectName) {
    objectName = objectName || 'doc';

    return propertiesInfo
      .map(p =>
        QueryGenerator._valueGetterSingle(`${objectName}['${p.propertyName}']`, p.propertyName, p.value, p.fromModel))
      .join(', ');
  }

  static _valueGetterSingle(prefix, propName, value, fromModelFn) {
    let valueGetter = prefix;

    if (value instanceof QueryOperator) {
      if (value.hasChildValues) {
        return `${QueryGenerator._valueGetterSingle(`${prefix}.value[0]`, propName, value.value[0], fromModelFn)}` +
          `, ${QueryGenerator._valueGetterSingle(`${prefix}.value[1]`, propName, value.value[1], fromModelFn)}`;
      }

      valueGetter = `${prefix}.value`;

      if (value.isInOperator && fromModelFn) {
        // Transform each individual value
        return `${valueGetter}.map(v => ${QueryGenerator._getMappingFunctionCall(propName, 'v')})`;
      }
    }

    return !fromModelFn ? valueGetter : QueryGenerator._getMappingFunctionCall(propName, valueGetter);
  }

  /**
   * Gets a string containing the doc properties to SET, considering QueryAssignment instances.
   * @param {Array} propertiesInfo
   * @param {String} [prefix='doc']
   * @return {string}
   * @private
   */
  static _assignmentGetterExpression(propertiesInfo, prefix) {
    prefix = prefix || 'doc';

    return propertiesInfo
      .map(p => {
        const valueGetter = `${prefix}['${p.propertyName}']${p.value instanceof QueryAssignment ? '.value' : ''}`;
        if (p.fromModel) {
          return QueryGenerator._getMappingFunctionCall(p.propertyName, valueGetter);
        }
        return valueGetter;
      })
      .join(', ');
  }

  static _getConditionWithOperators(propertiesInfo) {
    return propertiesInfo
      .map(p => QueryGenerator._getSingleCondition(p.columnName, p.value))
      .join(' AND ');
  }

  static _getMappingFunctionCall(propName, valueGetter) {
    return `mappingInfo.getFromModelFn('${propName}')(${valueGetter})`;
  }

  static _getSingleCondition(columnName, value) {
    if (value instanceof QueryOperator) {
      if (value.hasChildValues) {
        return `${QueryGenerator._getSingleCondition(columnName, value.value[0])}` +
          ` ${value.key} ${QueryGenerator._getSingleCondition(columnName, value.value[1])}`;
      }
      return `"${columnName}" ${value.key} ?`;
    }
    return `"${columnName}" = ?`;
  }
}

module.exports = QueryGenerator;