'use strict';

var Utils = require('../../utils')
  , Dot = require('dottie')
  , QueryTypes = require('../../query-types');

var AbstractQuery = function(database, sequelize, options) {};

/**
  The function takes the result of the query execution and groups
  the associated data by the callee.

  Example:
    groupJoinData([
      {
        some: 'data',
        id: 1,
        association: { foo: 'bar', id: 1 }
      }, {
        some: 'data',
        id: 1,
        association: { foo: 'bar', id: 2 }
      }, {
        some: 'data',
        id: 1,
        association: { foo: 'bar', id: 3 }
      }
    ])

  Result:
    Something like this:

    [
      {
        some: 'data',
        id: 1,
        association: [
          { foo: 'bar', id: 1 },
          { foo: 'bar', id: 2 },
          { foo: 'bar', id: 3 }
        ]
      }
    ]
*/
/*
 * Assumptions
 * ID is not necessarily the first field
 * All fields for a level is grouped in the same set (i.e. Panel.id, Task.id, Panel.title is not possible)
 * Parent keys will be seen before any include/child keys
 * Previous set won't necessarily be parent set (one parent could have two children, one child would then be previous set for the other)
 */

 /*
 * Author (MH) comment: This code is an unreadable mess, but its performant.
 * groupJoinData is a performance critical function so we prioritize perf over readability.
 */

var groupJoinData = function(rows, includeOptions, options) {
  if (!rows.length) {
    return [];
  }

  var
    // Generic looping
    i
    , length
    , $i
    , $length
    // Row specific looping
    , rowsI
    , rowsLength = rows.length
    , row
    // Key specific looping
    , keys
    , key
    , keyI
    , keyLength
    , prevKey
    , values
    , topValues
    , topExists
    , checkExisting = options.checkExisting
    // If we don't have to deduplicate we can pre-allocate the resulting array
    , results = checkExisting ? [] : new Array(rowsLength)
    , resultMap = {}
    , includeMap = {}
    , itemHash
    , parentHash
    , topHash
    // Result variables for the respective functions
    , $keyPrefix
    , $keyPrefixString
    , $prevKeyPrefixString
    , $prevKeyPrefix
    , $lastKeyPrefix
    , $current
    , $parent
    // Map each key to an include option
    , previousPiece
    , buildIncludeMap = function (piece) {
      if ($current.includeMap[piece]) {
        includeMap[key] = $current = $current.includeMap[piece];
        if (previousPiece) {
          previousPiece = previousPiece+'.'+piece;
        } else {
          previousPiece = piece;
        }
        includeMap[previousPiece] = $current;
      }
    }
    // Calcuate the last item in the array prefix ('Results' for 'User.Results.id')
    , lastKeyPrefixMemo = {}
    , lastKeyPrefix = function (key) {
      if (!lastKeyPrefixMemo[key]) {
        var prefix = keyPrefix(key)
          , length = prefix.length;

        lastKeyPrefixMemo[key] = !length ? '' : prefix[length - 1];
      }
      return lastKeyPrefixMemo[key];
    }
    // Calculate the string prefix of a key ('User.Results' for 'User.Results.id')
    , keyPrefixStringMemo = {}
    , keyPrefixString = function (key, memo) {
      if (!memo[key]) {
        memo[key] = key.substr(0, key.lastIndexOf('.'));
      }
      return memo[key];
    }
    // Removes the prefix from a key ('id' for 'User.Results.id')
    , removeKeyPrefixMemo = {}
    , removeKeyPrefix = function (key) {
      if (!removeKeyPrefixMemo[key]) {
        var index = key.lastIndexOf('.');
        removeKeyPrefixMemo[key] = key.substr(index === -1 ? 0 : index + 1);
      }
      return removeKeyPrefixMemo[key];
    }
    // Calculates the array prefix of a key (['User', 'Results'] for 'User.Results.id')
    , keyPrefixMemo = {}
    , keyPrefix = function (key) {
      // We use a double memo and keyPrefixString so that different keys with the same prefix will receive the same array instead of differnet arrays with equal values
      if (!keyPrefixMemo[key]) {
        var prefixString = keyPrefixString(key, keyPrefixStringMemo);
        if (!keyPrefixMemo[prefixString]) {
          keyPrefixMemo[prefixString] = prefixString ? prefixString.split('.') : [];
        }
        keyPrefixMemo[key] = keyPrefixMemo[prefixString];
      }
      return keyPrefixMemo[key];
    }
    , primaryKeyAttributes
    , prefix;

  for (rowsI = 0; rowsI < rowsLength; rowsI++) {
    row = rows[rowsI];

    // Keys are the same for all rows, so only need to compute them on the first row
    if (rowsI === 0) {
      keys = Object.keys(row);
      keyLength = keys.length;
    }

    if (checkExisting) {
      topExists = false;

      // Compute top level hash key (this is usually just the primary key values)
      $length = includeOptions.model.primaryKeyAttributes.length;
      if ($length === 1) {
        topHash = row[includeOptions.model.primaryKeyAttributes[0]];
      } else {
        topHash = '';
        for ($i = 0; $i < $length; $i++) {
          topHash += row[includeOptions.model.primaryKeyAttributes[$i]];
        }
      }
    }

    topValues = values = {};
    $prevKeyPrefix = undefined;
    for (keyI = 0; keyI < keyLength; keyI++) {
      key = keys[keyI];

      // The string prefix isn't actualy needed
      // We use it so keyPrefix for different keys will resolve to the same array if they have the same prefix
      // TODO: Find a better way?
      $keyPrefixString = keyPrefixString(key, keyPrefixStringMemo);
      $keyPrefix = keyPrefix(key);

      // On the first row we compute the includeMap
      if (rowsI === 0 && includeMap[key] === undefined) {
        if (!$keyPrefix.length) {
          includeMap[key] = includeMap[''] = includeOptions;
        } else {
          $current = includeOptions;
          previousPiece = undefined;
          $keyPrefix.forEach(buildIncludeMap);
        }
      }

      // End of key set
      if ($prevKeyPrefix !== undefined && $prevKeyPrefix !== $keyPrefix) {
        if (checkExisting) {
          // Compute hash key for this set instance
          // TODO: Optimize
          length = $prevKeyPrefix.length;
          $parent = null;
          parentHash = null;

          if (length) {
            for (i = 0; i < length; i++) {
              prefix = $parent ? $parent+'.'+$prevKeyPrefix[i] : $prevKeyPrefix[i];
              primaryKeyAttributes = includeMap[prefix].model.primaryKeyAttributes;
              $length = primaryKeyAttributes.length;
              if ($length === 1) {
                itemHash = prefix+row[prefix+'.'+primaryKeyAttributes[0]];
              } else {
                itemHash = prefix;
                for ($i = 0; $i < $length; $i++) {
                  itemHash += row[prefix+'.'+primaryKeyAttributes[$i]];
                }
              }
              if (!parentHash) {
                parentHash = topHash;
              }

              itemHash = parentHash + itemHash;
              $parent = prefix;
              if (i < length - 1) {
                parentHash = itemHash;
              }
            }
          } else {
            itemHash = topHash;
          }

          if (itemHash === topHash) {
            if (!resultMap[itemHash]) {
              resultMap[itemHash] = values;
            } else {
              topExists = true;
            }
          } else {
            if (!resultMap[itemHash]) {
              $parent = resultMap[parentHash];
              $lastKeyPrefix = lastKeyPrefix(prevKey);

              if (includeMap[prevKey].association.isSingleAssociation) {
                $parent[$lastKeyPrefix] = resultMap[itemHash] = values;
              } else {
                if (!$parent[$lastKeyPrefix]) {
                  $parent[$lastKeyPrefix] = [];
                }
                $parent[$lastKeyPrefix].push(resultMap[itemHash] = values);
              }
            }
          }

          // Reset values
          values = {};
        } else {
          // If checkExisting is false it's because there's only 1:1 associations in this query
          // However we still need to map onto the appropriate parent
          // For 1:1 we map forward, initializing the value object on the parent to be filled in the next iterations of the loop
          $current = topValues;
          length = $keyPrefix.length;
          if (length) {
            for (i = 0; i < length; i++) {
              if (i === length -1) {
                values = $current[$keyPrefix[i]] = {};
              }
              $current = $current[$keyPrefix[i]];
            }
          }
        }
      }

      // End of iteration, set value and set prev values (for next iteration)
      values[removeKeyPrefix(key)] = row[key];
      prevKey = key;
      $prevKeyPrefix = $keyPrefix;
      $prevKeyPrefixString = $keyPrefixString;
    }

    if (checkExisting) {
      length = $prevKeyPrefix.length;
      $parent = null;
      parentHash = null;

      if (length) {
        for (i = 0; i < length; i++) {
          prefix = $parent ? $parent+'.'+$prevKeyPrefix[i] : $prevKeyPrefix[i];
          primaryKeyAttributes = includeMap[prefix].model.primaryKeyAttributes;
          $length = primaryKeyAttributes.length;
          if ($length === 1) {
            itemHash = prefix+row[prefix+'.'+primaryKeyAttributes[0]];
          } else {
            itemHash = prefix;
            for ($i = 0; $i < $length; $i++) {
              itemHash += row[prefix+'.'+primaryKeyAttributes[$i]];
            }
          }
          if (!parentHash) {
            parentHash = topHash;
          }

          itemHash = parentHash + itemHash;
          $parent = prefix;
          if (i < length - 1) {
            parentHash = itemHash;
          }
        }
      } else {
        itemHash = topHash;
      }

      if (itemHash === topHash) {
        if (!resultMap[itemHash]) {
          resultMap[itemHash] = values;
        } else {
          topExists = true;
        }
      } else {
        if (!resultMap[itemHash]) {
          $parent = resultMap[parentHash];
          $lastKeyPrefix = lastKeyPrefix(prevKey);

          if (includeMap[prevKey].association.isSingleAssociation) {
            $parent[$lastKeyPrefix] = resultMap[itemHash] = values;
          } else {
            if (!$parent[$lastKeyPrefix]) {
              $parent[$lastKeyPrefix] = [];
            }
            $parent[$lastKeyPrefix].push(resultMap[itemHash] = values);
          }
        }
      }
      if (!topExists) {
        results.push(topValues);
      }
    } else {
      results[rowsI] = topValues;
    }
  }

  return results;
};

/**
 * Execute the passed sql query.
 *
 * Examples:
 *
 *     query.run('SELECT 1')
 *
 * @param {String} sql - The SQL query which should be executed.
 * @api public
 */
AbstractQuery.prototype.run = function() {
  throw new Error('The run method wasn\'t overwritten!');
};

/**
 * Check the logging option of the instance and print deprecation warnings.
 *
 * @return {void}
 */
AbstractQuery.prototype.checkLoggingOption = function() {
  if (this.options.logging === true) {
    console.log('DEPRECATION WARNING: The logging-option should be either a function or false. Default: console.log');
    this.options.logging = console.log;
  }

  if (this.options.logging === console.log) {
    // using just console.log will break in node < 0.6
    this.options.logging = function(s) { console.log(s); };
  }
};

/**
 * Get the attributes of an insert query, which contains the just inserted id.
 *
 * @return {String} The field name.
 */
AbstractQuery.prototype.getInsertIdField = function() {
  return 'insertId';
};

/**
 * Iterate over all known tables and search their names inside the sql query.
 * This method will also check association aliases ('as' option).
 *
 * @param  {String} attribute An attribute of a SQL query. (?)
 * @return {String}           The found tableName / alias.
 */
AbstractQuery.prototype.findTableNameInAttribute = function(attribute) {
  if (!this.options.include) {
    return null;
  }
  if (!this.options.includeNames) {
    this.options.includeNames = this.options.include.map(function(include) {
      return include.as;
    });
  }

  var tableNames = this.options.includeNames.filter(function(include) {
    return attribute.indexOf(include + '.') === 0;
  });

  if (tableNames.length === 1) {
    return tableNames[0];
  } else {
    return null;
  }
};

AbstractQuery.prototype.getUniqueConstraintErrorMessage = function(field) {
  var message = field + ' must be unique';
  var self = this;
  Object.keys(self.model.uniqueKeys).forEach(function(key) {
    if (self.model.uniqueKeys[key].fields.indexOf(field.replace(/"/g, '')) >= 0) {
      if (self.model.uniqueKeys[key].hasOwnProperty('msg')) {
        message = self.model.uniqueKeys[key].msg;
      }
    }
  });
  return message;
};

AbstractQuery.prototype.isRawQuery = function () {
  return this.options.type === QueryTypes.RAW;
};

AbstractQuery.prototype.isVersionQuery = function () {
  return this.options.type === QueryTypes.VERSION;
};

AbstractQuery.prototype.isUpsertQuery = function () {
  return this.options.type === QueryTypes.UPSERT;
};

AbstractQuery.prototype.isInsertQuery = function(results, metaData) {
  var result = true;

  if (this.options.type === QueryTypes.INSERT) {
    return true;
  }

  // is insert query if sql contains insert into
  result = result && (this.sql.toLowerCase().indexOf('insert into') === 0);

  // is insert query if no results are passed or if the result has the inserted id
  result = result && (!results || results.hasOwnProperty(this.getInsertIdField()));

  // is insert query if no metadata are passed or if the metadata has the inserted id
  result = result && (!metaData || metaData.hasOwnProperty(this.getInsertIdField()));

  return result;
};

AbstractQuery.prototype.handleInsertQuery = function(results, metaData) {
  if (this.instance) {
    // add the inserted row id to the instance
    var autoIncrementField = this.model.autoIncrementField
      , id = null;

    id = id || (results && results[this.getInsertIdField()]);
    id = id || (metaData && metaData[this.getInsertIdField()]);

    this.instance[autoIncrementField] = id;
  }
};

AbstractQuery.prototype.isShowTablesQuery = function() {
  return this.options.type === QueryTypes.SHOWTABLES;
};

AbstractQuery.prototype.handleShowTablesQuery = function(results) {
  return Utils._.flatten(results.map(function(resultSet) {
    return Utils._.values(resultSet);
  }));
};

AbstractQuery.prototype.isShowIndexesQuery = function () {
  return this.options.type === QueryTypes.SHOWINDEXES;
};

AbstractQuery.prototype.isDescribeQuery = function () {
  return this.options.type === QueryTypes.DESCRIBE;
};

AbstractQuery.prototype.isSelectQuery = function() {
  return this.options.type === QueryTypes.SELECT;
};

AbstractQuery.prototype.isBulkUpdateQuery = function() {
  return this.options.type === QueryTypes.BULKUPDATE;
};

AbstractQuery.prototype.isBulkDeleteQuery = function() {
  return this.options.type === QueryTypes.BULKDELETE;
};

AbstractQuery.prototype.isForeignKeysQuery = function() {
  return this.options.type === QueryTypes.FOREIGNKEYS;
};

AbstractQuery.prototype.isUpdateQuery = function() {
  return this.options.type === QueryTypes.UPDATE;
};

AbstractQuery.prototype.handleSelectQuery = function(results) {
  var result = null;

  // Raw queries
  if (this.options.raw) {
    result = results.map(function(result) {
      var o = {};

      for (var key in result) {
        if (result.hasOwnProperty(key)) {
          o[key] = result[key];
        }
      }

      if (this.options.nest) {
        o = Dot.transform(o);
      }

      return o;
    }, this);
  // Queries with include
  } else if (this.options.hasJoin === true) {
    results = groupJoinData(results, {
      model: this.model,
      includeMap: this.options.includeMap,
      includeNames: this.options.includeNames
    }, {
      checkExisting: this.options.hasMultiAssociation
    });

    result = this.model.bulkBuild(results, {
      isNewRecord: false,
      include: this.options.include,
      includeNames: this.options.includeNames,
      includeMap: this.options.includeMap,
      includeValidated: true,
      attributes: this.options.originalAttributes || this.options.attributes,
      raw: true
    });
  // Regular queries
  } else {
    result = this.model.bulkBuild(results, {
      isNewRecord: false,
      raw: true,
      attributes: this.options.attributes
    });
  }

  // return the first real model instance if options.plain is set (e.g. Model.find)
  if (this.options.plain) {
    result = (result.length === 0) ? null : result[0];
  }

  return result;
};

AbstractQuery.prototype.isShowOrDescribeQuery = function() {
  var result = false;

  result = result || (this.sql.toLowerCase().indexOf('show') === 0);
  result = result || (this.sql.toLowerCase().indexOf('describe') === 0);

  return result;
};

AbstractQuery.prototype.isCallQuery = function() {
  var result = false;

  result = result || (this.sql.toLowerCase().indexOf('call') === 0);

  return result;
};

AbstractQuery.$groupJoinData = groupJoinData;

module.exports = AbstractQuery;
