'use strict';

/*!
 * Module dependencies.
 */

const CastError = require('./error/cast');
const StrictModeError = require('./error/strict');
const Types = require('./schema/index');
const cast$expr = require('./helpers/query/cast$expr');
const castString = require('./cast/string');
const castTextSearch = require('./schema/operators/text');
const get = require('./helpers/get');
const getSchemaDiscriminatorByValue = require('./helpers/discriminator/getSchemaDiscriminatorByValue');
const isOperator = require('./helpers/query/isOperator');
const util = require('util');
const isObject = require('./helpers/isObject');
const isMongooseObject = require('./helpers/isMongooseObject');
const utils = require('./utils');

const ALLOWED_GEOWITHIN_GEOJSON_TYPES = ['Polygon', 'MultiPolygon'];

/**
 * Handles internal casting for query filters.
 *
 * @param {Schema} schema
 * @param {Object} obj Object to cast
 * @param {Object} [options] the query options
 * @param {Boolean|"throw"} [options.strict] Wheter to enable all strict options
 * @param {Boolean|"throw"} [options.strictQuery] Enable strict Queries
 * @param {Boolean} [options.sanitizeFilter] avoid adding implict query selectors ($in)
 * @param {Boolean} [options.upsert]
 * @param {Query} [context] passed to setters
 * @api private
 */
module.exports = function cast(schema, obj, options, context) {
  if (Array.isArray(obj)) {
    throw new Error('Query filter must be an object, got an array ', util.inspect(obj));
  }

  if (obj == null) {
    return obj;
  }

  if (schema != null && schema.discriminators != null && obj[schema.options.discriminatorKey] != null) {
    schema = getSchemaDiscriminatorByValue(schema, obj[schema.options.discriminatorKey]) || schema;
  }

  const paths = Object.keys(obj);
  let i = paths.length;
  let _keys;
  let any$conditionals;
  let schematype;
  let nested;
  let path;
  let type;
  let val;

  options = options || {};

  while (i--) {
    path = paths[i];
    val = obj[path];

    if (path === '$or' || path === '$nor' || path === '$and') {
      if (!Array.isArray(val)) {
        throw new CastError('Array', val, path);
      }
      for (let k = val.length - 1; k >= 0; k--) {
        if (val[k] == null || typeof val[k] !== 'object') {
          throw new CastError('Object', val[k], path + '.' + k);
        }
        const beforeCastKeysLength = Object.keys(val[k]).length;
        const discriminatorValue = val[k][schema.options.discriminatorKey];
        if (discriminatorValue == null) {
          val[k] = cast(schema, val[k], options, context);
        } else {
          const discriminatorSchema = getSchemaDiscriminatorByValue(context.schema, discriminatorValue);
          val[k] = cast(discriminatorSchema ? discriminatorSchema : schema, val[k], options, context);
        }

        if (Object.keys(val[k]).length === 0 && beforeCastKeysLength !== 0) {
          val.splice(k, 1);
        }
      }

      // delete empty: {$or: []} -> {}
      if (val.length === 0) {
        delete obj[path];
      }
    } else if (path === '$where') {
      type = typeof val;

      if (type !== 'string' && type !== 'function') {
        throw new Error('Must have a string or function for $where');
      }

      if (type === 'function') {
        obj[path] = val.toString();
      }

      continue;
    } else if (path === '$expr') {
      val = cast$expr(val, schema);
      continue;
    } else if (path === '$elemMatch') {
      val = cast(schema, val, options, context);
    } else if (path === '$text') {
      val = castTextSearch(val, path);
    } else if (path === '$comment' && !Object.hasOwn(schema.paths, '$comment')) {
      val = castString(val, path);
      obj[path] = val;
    } else {
      if (!schema) {
        // no casting for Mixed types
        continue;
      }

      schematype = schema.path(path);

      // Check for embedded discriminator paths
      if (!schematype) {
        const split = path.split('.');
        let j = split.length;
        while (j--) {
          const pathFirstHalf = split.slice(0, j).join('.');
          const pathLastHalf = split.slice(j).join('.');
          const _schematype = schema.path(pathFirstHalf);
          const discriminatorKey = _schematype &&
            _schematype.schema &&
            _schematype.schema.options &&
            _schematype.schema.options.discriminatorKey;

          // gh-6027: if we haven't found the schematype but this path is
          // underneath an embedded discriminator and the embedded discriminator
          // key is in the query, use the embedded discriminator schema
          if (_schematype != null &&
            (_schematype.schema && _schematype.schema.discriminators) != null &&
            discriminatorKey != null &&
            pathLastHalf !== discriminatorKey) {
            const discriminatorVal = get(obj, pathFirstHalf + '.' + discriminatorKey);
            const discriminators = _schematype.schema.discriminators;
            if (typeof discriminatorVal === 'string' && discriminators[discriminatorVal] != null) {

              schematype = discriminators[discriminatorVal].path(pathLastHalf);
            } else if (discriminatorVal != null &&
              Object.keys(discriminatorVal).length === 1 &&
              Array.isArray(discriminatorVal.$in) &&
              discriminatorVal.$in.length === 1 &&
              typeof discriminatorVal.$in[0] === 'string' &&
              discriminators[discriminatorVal.$in[0]] != null) {
              schematype = discriminators[discriminatorVal.$in[0]].path(pathLastHalf);
            }
          }
        }
      }

      if (!schematype) {
        // Handle potential embedded array queries
        const split = path.split('.');
        let j = split.length;
        let pathFirstHalf;
        let pathLastHalf;
        let remainingConds;

        // Find the part of the var path that is a path of the Schema
        while (j--) {
          pathFirstHalf = split.slice(0, j).join('.');
          schematype = schema.path(pathFirstHalf);
          if (schematype) {
            break;
          }
        }

        // If a substring of the input path resolves to an actual real path...
        if (schematype) {
          // Apply the casting; similar code for $elemMatch in schema/array.js
          if (schematype.caster && schematype.caster.schema) {
            remainingConds = {};
            pathLastHalf = split.slice(j).join('.');
            remainingConds[pathLastHalf] = val;

            const ret = cast(schematype.caster.schema, remainingConds, options, context)[pathLastHalf];
            if (ret === void 0) {
              delete obj[path];
            } else {
              obj[path] = ret;
            }
          } else {
            obj[path] = val;
          }
          continue;
        }

        if (isObject(val)) {
          // handle geo schemas that use object notation
          // { loc: { long: Number, lat: Number }

          let geo = '';
          if (val.$near) {
            geo = '$near';
          } else if (val.$nearSphere) {
            geo = '$nearSphere';
          } else if (val.$within) {
            geo = '$within';
          } else if (val.$geoIntersects) {
            geo = '$geoIntersects';
          } else if (val.$geoWithin) {
            geo = '$geoWithin';
          }

          if (geo) {
            const numbertype = new Types.Number('__QueryCasting__', null, null, schema);
            let value = val[geo];

            if (val.$maxDistance != null) {
              val.$maxDistance = numbertype.castForQuery(
                null,
                val.$maxDistance,
                context
              );
            }
            if (val.$minDistance != null) {
              val.$minDistance = numbertype.castForQuery(
                null,
                val.$minDistance,
                context
              );
            }

            if (geo === '$within') {
              const withinType = value.$center
                  || value.$centerSphere
                  || value.$box
                  || value.$polygon;

              if (!withinType) {
                throw new Error('Bad $within parameter: ' + JSON.stringify(val));
              }

              value = withinType;
            } else if (geo === '$near' &&
                typeof value.type === 'string' && Array.isArray(value.coordinates)) {
              // geojson; cast the coordinates
              value = value.coordinates;
            } else if ((geo === '$near' || geo === '$nearSphere' || geo === '$geoIntersects') &&
                value.$geometry && typeof value.$geometry.type === 'string' &&
                Array.isArray(value.$geometry.coordinates)) {
              if (value.$maxDistance != null) {
                value.$maxDistance = numbertype.castForQuery(
                  null,
                  value.$maxDistance,
                  context
                );
              }
              if (value.$minDistance != null) {
                value.$minDistance = numbertype.castForQuery(
                  null,
                  value.$minDistance,
                  context
                );
              }
              if (isMongooseObject(value.$geometry)) {
                value.$geometry = value.$geometry.toObject({
                  transform: false,
                  virtuals: false
                });
              }
              value = value.$geometry.coordinates;
            } else if (geo === '$geoWithin') {
              if (value.$geometry) {
                if (isMongooseObject(value.$geometry)) {
                  value.$geometry = value.$geometry.toObject({ virtuals: false });
                }
                const geoWithinType = value.$geometry.type;
                if (ALLOWED_GEOWITHIN_GEOJSON_TYPES.indexOf(geoWithinType) === -1) {
                  throw new Error('Invalid geoJSON type for $geoWithin "' +
                    geoWithinType + '", must be "Polygon" or "MultiPolygon"');
                }
                value = value.$geometry.coordinates;
              } else {
                value = value.$box || value.$polygon || value.$center ||
                  value.$centerSphere;
                if (isMongooseObject(value)) {
                  value = value.toObject({ virtuals: false });
                }
              }
            }

            _cast(value, numbertype, context);
            continue;
          }
        }

        if (schema.nested[path]) {
          continue;
        }

        const strict = 'strict' in options ? options.strict : schema.options.strict;
        const strictQuery = getStrictQuery(options, schema._userProvidedOptions, schema.options, context);
        if (options.upsert && strict) {
          if (strict === 'throw') {
            throw new StrictModeError(path);
          }
          throw new StrictModeError(path, 'Path "' + path + '" is not in ' +
            'schema, strict mode is `true`, and upsert is `true`.');
        } if (strictQuery === 'throw') {
          throw new StrictModeError(path, 'Path "' + path + '" is not in ' +
            'schema and strictQuery is \'throw\'.');
        } else if (strictQuery) {
          delete obj[path];
        }
      } else if (val == null) {
        continue;
      } else if (utils.isPOJO(val)) {
        any$conditionals = Object.keys(val).some(isOperator);

        if (!any$conditionals) {
          obj[path] = schematype.castForQuery(
            null,
            val,
            context
          );
        } else {
          const ks = Object.keys(val);
          let $cond;
          let k = ks.length;

          while (k--) {
            $cond = ks[k];
            nested = val[$cond];
            if ($cond === '$elemMatch') {
              if (nested && schematype != null && schematype.schema != null) {
                cast(schematype.schema, nested, options, context);
              } else if (nested && schematype != null && schematype.$isMongooseArray) {
                if (utils.isPOJO(nested) && nested.$not != null) {
                  cast(schema, nested, options, context);
                } else {
                  val[$cond] = schematype.castForQuery(
                    $cond,
                    nested,
                    context
                  );
                }
              }
            } else if ($cond === '$not') {
              if (nested && schematype) {
                _keys = Object.keys(nested);
                if (_keys.length && isOperator(_keys[0])) {
                  for (const key in nested) {
                    nested[key] = schematype.castForQuery(
                      key,
                      nested[key],
                      context
                    );
                  }
                } else {
                  val[$cond] = schematype.castForQuery(
                    $cond,
                    nested,
                    context
                  );
                }
                continue;
              }
            } else {
              val[$cond] = schematype.castForQuery(
                $cond,
                nested,
                context
              );
            }

          }
        }
      } else if (Array.isArray(val) && ['Buffer', 'Array'].indexOf(schematype.instance) === -1 && !options.sanitizeFilter) {
        const casted = [];
        const valuesArray = val;

        for (const _val of valuesArray) {
          casted.push(schematype.castForQuery(
            null,
            _val,
            context
          ));
        }

        obj[path] = { $in: casted };
      } else {
        obj[path] = schematype.castForQuery(
          null,
          val,
          context
        );
      }
    }
  }

  return obj;
};

function _cast(val, numbertype, context) {
  if (Array.isArray(val)) {
    val.forEach(function(item, i) {
      if (Array.isArray(item) || isObject(item)) {
        return _cast(item, numbertype, context);
      }
      val[i] = numbertype.castForQuery(null, item, context);
    });
  } else {
    const nearKeys = Object.keys(val);
    let nearLen = nearKeys.length;
    while (nearLen--) {
      const nkey = nearKeys[nearLen];
      const item = val[nkey];
      if (Array.isArray(item) || isObject(item)) {
        _cast(item, numbertype, context);
        val[nkey] = item;
      } else {
        val[nkey] = numbertype.castForQuery({ val: item, context: context });
      }
    }
  }
}

function getStrictQuery(queryOptions, schemaUserProvidedOptions, schemaOptions, context) {
  if ('strictQuery' in queryOptions) {
    return queryOptions.strictQuery;
  }
  if ('strictQuery' in schemaUserProvidedOptions) {
    return schemaUserProvidedOptions.strictQuery;
  }
  const mongooseOptions = context &&
    context.mongooseCollection &&
    context.mongooseCollection.conn &&
    context.mongooseCollection.conn.base &&
    context.mongooseCollection.conn.base.options;
  if (mongooseOptions) {
    if ('strictQuery' in mongooseOptions) {
      return mongooseOptions.strictQuery;
    }
  }
  return schemaOptions.strictQuery;
}
