/**
Copyright: 2024 Contrast Security, Inc
  Contact: support@contrastsecurity.com
  License: Commercial

  NOTICE: This Software and the patented inventions embodied within may only be
  used as part of Contrast Security’s commercial offerings. Even though it is
  made available through public repositories, use of this Software is subject to
  the applicable End User Licensing Agreement found at
  https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
  between Contrast Security and the End User. The Software may not be reverse
  engineered, modified, repackaged, sold, redistributed or otherwise used in a
  way not consistent with the End User License Agreement.
*/
'use strict';

const { executeConditionals, notOnlyConditionals } = require('./conditionals');
const { evalInRefSchema } = require('./refs');
const fastDeepEqual = require('fast-deep-equal');
const { objectWalk } = require('./object-walk');

class SchemaContext {
  constructor(shadow, schema, object, key, options) {
    const schemaName = schema.$id || '';

    // the ajv shadow
    this.shadow = shadow;
    // copy the shadow properties to localize knowledge of their implementation
    this.schemaMap = shadow.schemaMap;
    this.regexCache = shadow.regexCache;
    this.logger = shadow.logger;
    // and make sure the schema is in the map
    this.addSchemaToMap(schemaName, schema);

    // the schema being evaluated.
    this.schema = schema;
    this.schemaName = schemaName;
    this.options = options;
    // keep track of the current part of the schema
    this.curSchemaIx = 0;
    this.schemaStack = [schemaName];
    // keep track of what place in the data is being validated
    this.root = object;
    this.kPath = [key];
    this.oPath = [object];
    this.data = object[key];
    // the type being evaluated
    this.type = undefined;
    // the schema did not specify a type; it was inferred from the data
    this.inferredType = false;
    // the validation was done by a boolean, i.e., it is unconditionally
    // valid or not, so valid means nothing from a security standpoint.
    this.boolean = false;
    // the current validation is under a "not", i.e., valid is not valid
    this.not = false;
    // used to validate a property name, not data, so only the string shouldn't
    // be tagged if it passes validation.
    this.validateOnly = false;
    // if all errors are wanted and ajv is configured so, honor it.

    // both options and ajv's setting must agree unless the option is the string "true",
    // in which case it overrides ajv's setting.
    this.allErrors = options.allErrors && shadow.ajv.opts.allErrors;
    if (options.allErrors === 'true') {
      this.allErrors = true;
    }

    // this function is called when tag status should be changed for a
    // value.
    //
    // signature of tagFunction
    // @param {object} object the container holding the item to be tagged
    // @param {string} key of the item in the container to be tagged
    // @param {object} action specifies action and action-specific arguments
    // action.fn === 'add' then action.tags[] contains the tags to add to the item
    // action.fn === 'clear' then remove tracking from the item
    this.tagFunction = options.tagFunction || (() => undefined);

    this.tagModificationCount = 0;

    // augment with conditionals using Object.defineProperties.
    Object.defineProperties(this, {
      executeConditionals: {
        value: executeConditionals,
        enumerable: true
      },
      evalInRefSchema: {
        value: evalInRefSchema,
        enumerable: true
      }
    });

    // prevent object variants/hidden classes.
    Object.seal(this);
  }

  /**
   * evaluates data against a schema. it captures keys that can be used in any
   * schema - type, enum, const - and dispatches to type-specific functions.
   *
   * this.data === this.oPath[this.oPath.length - 1][this.kPath[this.kPath.length - 1]];
   * this.data is an implicit parameter to almost every function in this class.
   *
   * @param {object|boolean} schema
   * @param {object} ctx context for the evaluation, as defined above in evaluate().
   * @returns {boolean} validation result
   */
  // eslint-disable-next-line complexity
  evaluate(schema) {
    this.inferredType = false;
    // don't do any validation of strings if the schema is boolean. while
    // validation might succeed, nothing was actually validated.
    if (typeof schema === 'boolean') {
      this.boolean = true;
      return schema;
    }
    this.boolean = false;

    // if it's a reference fetch and evaluate using that reference. all the examples
    // i have found show an object with one property, $ref, e.g., {$ref: 'reference'}.
    if ('$ref' in schema) {
      return this.evalInRefSchema(schema.$ref);
    }

    let { type } = schema;
    let dataType = typeof this.data;
    if (dataType === 'number' && this.data % 1 === 0) {
      dataType = 'integer';
    } else if (Array.isArray(this.data)) {
      dataType = 'array';
    } else if (this.data === null) {
      dataType = 'null';
    }

    // there should not be an array of zero-length because ajv should have
    // thrown when it was added, but just in case.
    if (Array.isArray(type)) {
      if (!type.includes(dataType) && !this.allErrors) {
        return false;
      } else if (type.length === 0) {
        type = undefined;
      }
    }

    // if type was not specified set it to the data type
    if (!type) {
      this.inferredType = true;
      type = dataType;
    }

    // if not an array (it's either a string or an array)
    if (!Array.isArray(type)) {
      this.type = type;
      return this.typeSpecificEval(schema);
    }

    // handle union types (requires allowUnionTypes: true)
    for (const t of type) {
      this.type = t;
      if (this.typeSpecificEval(schema)) {
        return true;
      }
    }
    return false;
  }

  // eslint-disable-next-line complexity
  typeSpecificEval(schema) {
    const { type } = this;

    const dispatch = SchemaContext.typeDispatch.get(type);
    if (!dispatch) {
      this.logger.trace(`unexpected type ${type}`);
      return false;
    }

    //
    // ajv allows both an enum and const to be present even if they are
    // mutually exclusive. it will fail validation in that case; even though
    // this code should only be invoked when ajv's validation has succeeded,
    // testing requires handling both success and failure conditions.
    //
    let valid = true;
    let enumChecked = false;
    if ('const' in schema) {
      enumChecked = true;
      valid = this[dispatch.enumerator]([schema.const], this.data);
    }
    if ('enum' in schema) {
      enumChecked = true;
      valid = this[dispatch.enumerator](schema.enum, this.data) && valid;
    }

    // if a enum validated the data and this is not under a "not" then
    // clear tags.
    if (enumChecked && valid && !this.not && dispatch.clear) {
      this[dispatch.clear]();
      if (!this.allErrors) {
        return true;
      }
    }

    // conditionals can exist with or without a top-level type specified. e.g.,
    // {not: {...}} as well as {type: integer, {not: {...}}}
    // execute any that are present now.
    if (!this.executeConditionals(schema)) {
      // if opts.allErrors returning is not correct; continue executing/validating.
      valid = false;
      if (!this.allErrors) {
        return false;
      }
    }

    //
    // if a type was specified or inferred and there are properties other than conditionals
    // then dispatch to the type-specific handler.
    //
    if (type && notOnlyConditionals(schema)) {
      valid = dispatch.method(schema, this) && valid;
    }

    return valid;
  }

  /**
   * @param {any[]} enums an array of constant values that are valid
   * @returns {boolean} true if this.data matched one of the enum elements
   */
  scalarEnum(enums, data) {
    return enums.includes(data);
  }

  /**
   * walk either an array or object and find an enumerated valid match. this
   * must not be called unless enums is an array of {any} to compare against
   * this.data.
   *
   * this.data is compared (deep equal) against each element of enums. enum:
   * "an instance validates if its value is equal to one of the elements in
   * this keyword's array value".
   *
   * @param {any[]} enums each element of enums is a valid value for data
   * @returns {boolean} true if data matches an element of enums, else false
   */
  objectEnum(enums, data) {
    let valid = false;
    for (const e of enums) {
      if (fastDeepEqual(e, data)) {
        valid = true;
        break;
      }
    }

    return valid;
  }

  /**
   * drop one level deeper into this.data, i.e., this.data[prop]. this is used
   * when dropping down a level into the schema as well as the data.
   *
   * @param {schema} schema JSON Schema to evaluate this.data[prop]
   * @param {string} prop the prop to drop into
   */
  evalInto(schema, prop) {
    this.dataPathPush(prop);
    const valid = this.evaluate(schema);
    this.dataPathPop();
    return valid;
  }

  dataPathPush(prop) {
    if (typeof this.data !== 'object' || this.data === null) {
      throw new Error('dataPathPush() into non-object', this.data);
    }
    this.kPath.push(prop);
    this.oPath.push(this.data);

    this.data = this.data[prop];
  }

  dataPathPop() {
    this.kPath.pop();
    this.data = this.oPath.pop();
  }

  schemaStackPush(schemaId, schema) {
    if (!this.schemaMap.get(schemaId)) {
      this.addSchemaToMap(schemaId, schema);
    }

    this.curSchemaIx = this.schemaStack.push(schemaId) - 1;
  }

  schemaStackPop() {
    const schemaId = this.schemaStack.pop();
    this.curSchemaIx -= 1;
    return schemaId;
  }

  getCurrentObjectAndKey() {
    const lastObject = this.oPath[this.oPath.length - 1];
    const lastKey = this.kPath[this.kPath.length - 1];
    if (lastObject[lastKey] !== this.data) {
      throw new Error('lastObject[lastKey] !== this.data');
    }
    return [lastObject, lastKey];
  }

  clearStringTracking() {
    const [object, key] = this.getCurrentObjectAndKey();
    this.clearTracking(object, key);
  }

  clearObjectTracking() {
    for (const [object, key] of objectWalk(this.data)) {
      if (typeof object[key] === 'string') {
        this.clearTracking(object, key);
      }
    }
  }

  clearTracking(object, key) {
    this.tagModificationCount += 1;
    this.tagFunction(object, key, { fn: 'clear' });
  }

  addTags(object, key, tags) {
    if (typeof tags === 'string') {
      tags = [tags];
    }
    this.tagModificationCount += 1;
    this.tagFunction(object, key, { fn: 'add', tags });
  }

  /**
   * Store the specified schema in the map using the supplied key. If the
   * schema parses as a URL, store the parsed url object in addition to the
   * schema. It is used to resolved ajv's wide-ranging $ref forms.
   *
   * @param {string} schemaName the name to use as the key for the schema
   * @param {object|boolean} schema the schema to store in the map
   */
  addSchemaToMap(schemaName, schema) {
    // if it's already in the map don't reset it
    if (this.schemaMap.get(schemaName)) {
      return;
    }

    let schemaUrlObject;
    try {
      schemaUrlObject = new URL(schema.$id);
    } catch (e) {
      this.schemaMap.set(schemaName, { schema });
      return;
    }
    this.schemaMap.set(schemaName, { schema, url: schemaUrlObject });
  }

  getRegex(stringRegex) {
    let re = this.regexCache.get(stringRegex);
    if (this.regexCache.has(stringRegex)) {
      return re;
    }
    try {
      re = new RegExp(stringRegex);
      this.regexCache.set(stringRegex, re);
      return re;
    } catch (e) {
      this.regexCache.set(stringRegex, null);
      return null;
    }
  }
}

const t = require('./json-schema-type-evaluators');

// map each JSON Schema type to the method name for it
SchemaContext.typeDispatch = new Map([
  ['null', { method: t.null, enumerator: 'scalarEnum' }],
  ['boolean', { method: t.boolean, enumerator: 'scalarEnum' }],
  ['number', { method: t.number, enumerator: 'scalarEnum' }],
  ['integer', { method: t.number, enumerator: 'scalarEnum' }],
  ['string', { method: t.string, enumerator: 'scalarEnum', clear: 'clearStringTracking' }],
  ['array', { method: t.array, enumerator: 'objectEnum', clear: 'clearObjectTracking' }],
  ['object', { method: t.object, enumerator: 'objectEnum', clear: 'clearObjectTracking' }],
]);

module.exports = SchemaContext;
