// This file contains helpers for running operations in REST format.
// The goal is that handlers that explicitly handle an express route
// should just be shallow wrappers around things in this file, but
// these functions should not explicitly depend on the request
// object.
// This means that one of these handlers can support multiple
// routes. That's useful for the routes that do really similar
// things.

var Parse = require('parse/node').Parse;

var RestQuery = require('./RestQuery');
var RestWrite = require('./RestWrite');
var triggers = require('./triggers');
const { enforceRoleSecurity } = require('./SharedRest');
const { createSanitizedError } = require('./Error');

function checkTriggers(className, config, types) {
  return types.some(triggerType => {
    return triggers.getTrigger(className, triggers.Types[triggerType], config.applicationId);
  });
}

function checkLiveQuery(className, config) {
  return config.liveQueryController && config.liveQueryController.hasLiveQuery(className);
}
async function runFindTriggers(
  config,
  auth,
  className,
  restWhere,
  restOptions,
  clientSDK,
  context,
  options = {}
) {
  const { isGet } = options;

  if (restOptions && restOptions.explain && !auth.isMaster) {
    const allowPublicExplain = config.databaseOptions?.allowPublicExplain ?? false;

    if (!allowPublicExplain) {
      throw new Parse.Error(
        Parse.Error.INVALID_QUERY,
        'Using the explain query parameter requires the master key'
      );
    }
  }

  // Run beforeFind trigger - may modify query or return objects directly
  const result = await triggers.maybeRunQueryTrigger(
    triggers.Types.beforeFind,
    className,
    restWhere,
    restOptions,
    config,
    auth,
    context,
    isGet
  );

  restWhere = result.restWhere || restWhere;
  restOptions = result.restOptions || restOptions;

  // Short-circuit path: beforeFind returned objects directly
  // Security risk: These objects may have been fetched with master privileges
  if (result?.objects) {
    const objectsFromBeforeFind = result.objects;

    let objectsForAfterFind = objectsFromBeforeFind;

    // Security check: Re-filter objects if not master to ensure ACL/CLP compliance
    if (!auth?.isMaster && !auth?.isMaintenance) {
      const ids = (Array.isArray(objectsFromBeforeFind) ? objectsFromBeforeFind : [objectsFromBeforeFind])
        .map(o => (o && (o.id || o.objectId)) || null)
        .filter(Boolean);

      // Objects without IDs are(normally) unsaved objects
      // For unsaved objects, the ACL security does not apply, so no need to redo the query.
      // For saved objects, we need to re-query to ensure proper ACL/CLP enforcement
      if (ids.length > 0) {
        const refilterWhere = isGet ? { objectId: ids[0] } : { objectId: { $in: ids } };

        // Re-query with proper security: no triggers to avoid infinite loops
        const refilterQuery = await RestQuery({
          method: isGet ? RestQuery.Method.get : RestQuery.Method.find,
          config,
          auth,
          className,
          restWhere: refilterWhere,
          restOptions,
          clientSDK,
          context,
          runBeforeFind: false,
          runAfterFind: false,
        });

        const refiltered = await refilterQuery.execute();
        objectsForAfterFind = (refiltered && refiltered.results) || [];
      }
    }

    // Run afterFind trigger on security-filtered objects
    const afterFindProcessedObjects = await triggers.maybeRunAfterFindTrigger(
      triggers.Types.afterFind,
      auth,
      className,
      objectsForAfterFind,
      config,
      new Parse.Query(className).withJSON({ where: restWhere, ...restOptions }),
      context,
      isGet
    );

    return {
      results: afterFindProcessedObjects,
    };
  }

  // Normal path: execute database query with modified conditions
  const query = await RestQuery({
    method: isGet ? RestQuery.Method.get : RestQuery.Method.find,
    config,
    auth,
    className,
    restWhere,
    restOptions,
    clientSDK,
    context,
    runBeforeFind: false,
  });

  return query.execute();
}

// Returns a promise for an object with optional keys 'results' and 'count'.
const find = async (config, auth, className, restWhere, restOptions, clientSDK, context) => {
  enforceRoleSecurity('find', className, auth, config);
  return runFindTriggers(
    config,
    auth,
    className,
    restWhere,
    restOptions,
    clientSDK,
    context,
    { isGet: false }
  );
};

// get is just like find but only queries an objectId.
const get = async (config, auth, className, objectId, restOptions, clientSDK, context) => {
  enforceRoleSecurity('get', className, auth, config);
  return runFindTriggers(
    config,
    auth,
    className,
    { objectId },
    restOptions,
    clientSDK,
    context,
    { isGet: true }
  );
};

// Returns a promise that doesn't resolve to any useful value.
function del(config, auth, className, objectId, context) {
  if (typeof objectId !== 'string') {
    throw new Parse.Error(Parse.Error.INVALID_JSON, 'bad objectId');
  }

  if (className === '_User' && auth.isUnauthenticated()) {
    throw new Parse.Error(Parse.Error.SESSION_MISSING, 'Insufficient auth to delete user');
  }

  enforceRoleSecurity('delete', className, auth, config);

  let inflatedObject;
  let schemaController;

  return Promise.resolve()
    .then(async () => {
      const hasTriggers = checkTriggers(className, config, ['beforeDelete', 'afterDelete']);
      const hasLiveQuery = checkLiveQuery(className, config);
      if (hasTriggers || hasLiveQuery || className == '_Session') {
        const query = await RestQuery({
          method: RestQuery.Method.get,
          config,
          auth,
          className,
          restWhere: { objectId },
        });
        return query.execute({ op: 'delete' }).then(response => {
          if (response && response.results && response.results.length) {
            const firstResult = response.results[0];
            firstResult.className = className;
            if (className === '_Session' && !auth.isMaster && !auth.isMaintenance) {
              if (!auth.user || firstResult.user.objectId !== auth.user.id) {
                throw createSanitizedError(Parse.Error.INVALID_SESSION_TOKEN, 'Invalid session token', config);
              }
            }
            var cacheAdapter = config.cacheController;
            cacheAdapter.user.del(firstResult.sessionToken);
            inflatedObject = Parse.Object.fromJSON(firstResult);
            return triggers.maybeRunTrigger(
              triggers.Types.beforeDelete,
              auth,
              inflatedObject,
              null,
              config,
              context
            );
          }
          throw new Parse.Error(Parse.Error.OBJECT_NOT_FOUND, 'Object not found for delete.');
        });
      }
      return Promise.resolve({});
    })
    .then(() => {
      if (!auth.isMaster && !auth.isMaintenance) {
        return auth.getUserRoles();
      } else {
        return;
      }
    })
    .then(() => config.database.loadSchema())
    .then(s => {
      schemaController = s;
      const options = {};
      if (!auth.isMaster && !auth.isMaintenance) {
        options.acl = ['*'];
        if (auth.user) {
          options.acl.push(auth.user.id);
          options.acl = options.acl.concat(auth.userRoles);
        }
      }

      return config.database.destroy(
        className,
        {
          objectId: objectId,
        },
        options,
        schemaController
      );
    })
    .then(() => {
      // Notify LiveQuery server if possible
      const perms = schemaController.getClassLevelPermissions(className);
      config.liveQueryController.onAfterDelete(className, inflatedObject, null, perms);
      return triggers.maybeRunTrigger(
        triggers.Types.afterDelete,
        auth,
        inflatedObject,
        null,
        config,
        context
      );
    })
    .catch(error => {
      handleSessionMissingError(error, className, auth, config);
    });
}

// Returns a promise for a {response, status, location} object.
function create(config, auth, className, restObject, clientSDK, context) {
  enforceRoleSecurity('create', className, auth, config);
  var write = new RestWrite(config, auth, className, null, restObject, null, clientSDK, context);
  return write.execute();
}

// Returns a promise that contains the fields of the update that the
// REST API is supposed to return.
// Usually, this is just updatedAt.
function update(config, auth, className, restWhere, restObject, clientSDK, context) {
  enforceRoleSecurity('update', className, auth, config);

  return Promise.resolve()
    .then(async () => {
      const hasTriggers = checkTriggers(className, config, ['beforeSave', 'afterSave']);
      const hasLiveQuery = checkLiveQuery(className, config);
      if (hasTriggers || hasLiveQuery) {
        // Do not use find, as it runs the before finds
        const query = await RestQuery({
          method: RestQuery.Method.get,
          config,
          auth,
          className,
          restWhere,
          runAfterFind: false,
          runBeforeFind: false,
          context,
        });
        return query.execute({
          op: 'update',
        });
      }
      return Promise.resolve({});
    })
    .then(({ results }) => {
      var originalRestObject;
      if (results && results.length) {
        originalRestObject = results[0];
      }
      return new RestWrite(
        config,
        auth,
        className,
        restWhere,
        restObject,
        originalRestObject,
        clientSDK,
        context,
        'update'
      ).execute();
    })
    .catch(error => {
      handleSessionMissingError(error, className, auth, config);
    });
}

function handleSessionMissingError(error, className, auth, config) {
  // If we're trying to update a user without / with bad session token
  if (
    className === '_User' &&
    error.code === Parse.Error.OBJECT_NOT_FOUND &&
    !auth.isMaster &&
    !auth.isMaintenance
  ) {
    throw createSanitizedError(Parse.Error.SESSION_MISSING, 'Insufficient auth.', config);
  }
  throw error;
}

module.exports = {
  create,
  del,
  find,
  get,
  update,
};
