import MongoCollection from './MongoCollection';
import Parse from 'parse/node';

function mongoFieldToParseSchemaField(type) {
  if (type[0] === '*') {
    return {
      type: 'Pointer',
      targetClass: type.slice(1),
    };
  }
  if (type.startsWith('relation<')) {
    return {
      type: 'Relation',
      targetClass: type.slice('relation<'.length, type.length - 1),
    };
  }
  switch (type) {
    case 'number':
      return { type: 'Number' };
    case 'string':
      return { type: 'String' };
    case 'boolean':
      return { type: 'Boolean' };
    case 'date':
      return { type: 'Date' };
    case 'map':
    case 'object':
      return { type: 'Object' };
    case 'array':
      return { type: 'Array' };
    case 'geopoint':
      return { type: 'GeoPoint' };
    case 'file':
      return { type: 'File' };
    case 'bytes':
      return { type: 'Bytes' };
    case 'polygon':
      return { type: 'Polygon' };
  }
}

const nonFieldSchemaKeys = ['_id', '_metadata', '_client_permissions'];
function mongoSchemaFieldsToParseSchemaFields(schema) {
  var fieldNames = Object.keys(schema).filter(key => nonFieldSchemaKeys.indexOf(key) === -1);
  var response = fieldNames.reduce((obj, fieldName) => {
    obj[fieldName] = mongoFieldToParseSchemaField(schema[fieldName]);
    if (
      schema._metadata &&
      schema._metadata.fields_options &&
      schema._metadata.fields_options[fieldName]
    ) {
      obj[fieldName] = Object.assign(
        {},
        obj[fieldName],
        schema._metadata.fields_options[fieldName]
      );
    }
    return obj;
  }, {});
  response.ACL = { type: 'ACL' };
  response.createdAt = { type: 'Date' };
  response.updatedAt = { type: 'Date' };
  response.objectId = { type: 'String' };
  return response;
}

const emptyCLPS = Object.freeze({
  find: {},
  count: {},
  get: {},
  create: {},
  update: {},
  delete: {},
  addField: {},
  protectedFields: {},
});

const defaultCLPS = Object.freeze({
  find: { '*': true },
  count: { '*': true },
  get: { '*': true },
  create: { '*': true },
  update: { '*': true },
  delete: { '*': true },
  addField: { '*': true },
  protectedFields: { '*': [] },
});

function mongoSchemaToParseSchema(mongoSchema) {
  let clps = defaultCLPS;
  let indexes = {};
  if (mongoSchema._metadata) {
    if (mongoSchema._metadata.class_permissions) {
      clps = { ...emptyCLPS, ...mongoSchema._metadata.class_permissions };
    }
    if (mongoSchema._metadata.indexes) {
      indexes = { ...mongoSchema._metadata.indexes };
    }
  }
  return {
    className: mongoSchema._id,
    fields: mongoSchemaFieldsToParseSchemaFields(mongoSchema),
    classLevelPermissions: clps,
    indexes: indexes,
  };
}

function _mongoSchemaQueryFromNameQuery(name: string, query) {
  const object = { _id: name };
  if (query) {
    Object.keys(query).forEach(key => {
      object[key] = query[key];
    });
  }
  return object;
}

// Returns a type suitable for inserting into mongo _SCHEMA collection.
// Does no validation. That is expected to be done in Parse Server.
function parseFieldTypeToMongoFieldType({ type, targetClass }) {
  switch (type) {
    case 'Pointer':
      return `*${targetClass}`;
    case 'Relation':
      return `relation<${targetClass}>`;
    case 'Number':
      return 'number';
    case 'String':
      return 'string';
    case 'Boolean':
      return 'boolean';
    case 'Date':
      return 'date';
    case 'Object':
      return 'object';
    case 'Array':
      return 'array';
    case 'GeoPoint':
      return 'geopoint';
    case 'File':
      return 'file';
    case 'Bytes':
      return 'bytes';
    case 'Polygon':
      return 'polygon';
  }
}

class MongoSchemaCollection {
  _collection: MongoCollection;

  constructor(collection: MongoCollection) {
    this._collection = collection;
  }

  _fetchAllSchemasFrom_SCHEMA() {
    return this._collection._rawFind({}).then(schemas => schemas.map(mongoSchemaToParseSchema));
  }

  _fetchOneSchemaFrom_SCHEMA(name: string) {
    return this._collection
      ._rawFind(_mongoSchemaQueryFromNameQuery(name), { limit: 1 })
      .then(results => {
        if (results.length === 1) {
          return mongoSchemaToParseSchema(results[0]);
        } else {
          throw undefined;
        }
      });
  }

  // Atomically find and delete an object based on query.
  findAndDeleteSchema(name: string) {
    return this._collection._mongoCollection.findOneAndDelete(_mongoSchemaQueryFromNameQuery(name));
  }

  insertSchema(schema: any) {
    return this._collection
      .insertOne(schema)
      .then(() => mongoSchemaToParseSchema(schema))
      .catch(error => {
        if (error.code === 11000) {
          //Mongo's duplicate key error
          throw new Parse.Error(Parse.Error.DUPLICATE_VALUE, 'Class already exists.');
        } else {
          throw error;
        }
      });
  }

  updateSchema(name: string, update) {
    return this._collection.updateOne(_mongoSchemaQueryFromNameQuery(name), update);
  }

  upsertSchema(name: string, query: string, update) {
    return this._collection.upsertOne(_mongoSchemaQueryFromNameQuery(name, query), update);
  }

  // Add a field to the schema. If database does not support the field
  // type (e.g. mongo doesn't support more than one GeoPoint in a class) reject with an "Incorrect Type"
  // Parse error with a desciptive message. If the field already exists, this function must
  // not modify the schema, and must reject with DUPLICATE_VALUE error.
  // If this is called for a class that doesn't exist, this function must create that class.

  // TODO: throw an error if an unsupported field type is passed. Deciding whether a type is supported
  // should be the job of the adapter. Some adapters may not support GeoPoint at all. Others may
  // Support additional types that Mongo doesn't, like Money, or something.

  // TODO: don't spend an extra query on finding the schema if the type we are trying to add isn't a GeoPoint.
  addFieldIfNotExists(className: string, fieldName: string, fieldType: string) {
    return this._fetchOneSchemaFrom_SCHEMA(className)
      .then(
        schema => {
          // If a field with this name already exists, it will be handled elsewhere.
          if (schema.fields[fieldName] !== undefined) {
            return;
          }
          // The schema exists. Check for existing GeoPoints.
          if (fieldType.type === 'GeoPoint') {
            // Make sure there are not other geopoint fields
            if (
              Object.keys(schema.fields).some(
                existingField => schema.fields[existingField].type === 'GeoPoint'
              )
            ) {
              throw new Parse.Error(
                Parse.Error.INCORRECT_TYPE,
                'MongoDB only supports one GeoPoint field in a class.'
              );
            }
          }
          return;
        },
        error => {
          // If error is undefined, the schema doesn't exist, and we can create the schema with the field.
          // If some other error, reject with it.
          if (error === undefined) {
            return;
          }
          throw error;
        }
      )
      .then(() => {
        const { type, targetClass, ...fieldOptions } = fieldType;
        // We use $exists and $set to avoid overwriting the field type if it
        // already exists. (it could have added inbetween the last query and the update)
        if (fieldOptions && Object.keys(fieldOptions).length > 0) {
          return this.upsertSchema(
            className,
            { [fieldName]: { $exists: false } },
            {
              $set: {
                [fieldName]: parseFieldTypeToMongoFieldType({
                  type,
                  targetClass,
                }),
                [`_metadata.fields_options.${fieldName}`]: fieldOptions,
              },
            }
          );
        } else {
          return this.upsertSchema(
            className,
            { [fieldName]: { $exists: false } },
            {
              $set: {
                [fieldName]: parseFieldTypeToMongoFieldType({
                  type,
                  targetClass,
                }),
              },
            }
          );
        }
      });
  }

  async updateFieldOptions(className: string, fieldName: string, fieldType: any) {
    const { ...fieldOptions } = fieldType;
    delete fieldOptions.type;
    delete fieldOptions.targetClass;

    await this.upsertSchema(
      className,
      { [fieldName]: { $exists: true } },
      {
        $set: {
          [`_metadata.fields_options.${fieldName}`]: fieldOptions,
        },
      }
    );
  }
}

// Exported for testing reasons and because we haven't moved all mongo schema format
// related logic into the database adapter yet.
MongoSchemaCollection._TESTmongoSchemaToParseSchema = mongoSchemaToParseSchema;
MongoSchemaCollection.parseFieldTypeToMongoFieldType = parseFieldTypeToMongoFieldType;

export default MongoSchemaCollection;
