/**
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 fastDeepEqual = require('fast-deep-equal');

//
// type: null
//
function t_null(schema, ctx) {
  // there are no keywords for the null type.
  return ctx.data === null;
}

//
// type: boolean
//
function t_boolean(schema, ctx) {
  // there are no keywords for boolean types.
  return typeof ctx.data === 'boolean';
}

//
// type: number, integer
//
// eslint-disable-next-line complexity
function t_number(schema, ctx) {

  // ajv's number evaluation order
  // integer (if type: integer)
  // maximum
  // minimum
  // exclusiveMaximum
  // exclusiveMinimum
  // multipleOf

  let valid = false;

  if (typeof ctx.data === 'number') {
    valid = ctx.type === 'number' || (ctx.type === 'integer' && ctx.data % 1 === 0);
  }
  if (!valid && !ctx.allErrors) {
    return false;
  }

  if ('maximum' in schema && ctx.data > schema.maximum) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  if ('minimum' in schema && ctx.data < schema.minimum) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  if ('exclusiveMaximum' in schema && ctx.data >= schema.exclusiveMaximum) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  if ('exclusiveMinimum' in schema && ctx.data <= schema.exclusiveMinimum) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  if ('multipleOf' in schema && (ctx.data / schema.multipleOf) % 1 !== 0) {
    valid = false;
  }

  return valid;
}

//
// type: string
//
// eslint-disable-next-line complexity
function t_string(schema, ctx) {
  // ajv's string evaluation order
  // maxLength
  // minLength
  // pattern

  let valid = typeof ctx.data === 'string';

  // the data is a string, so add the string tag. it doesn't matter whether
  // the string passes any kind of validation or not. if validateOnly is true
  // then this is being used to validate a non-data string, e.g., a property name.
  if (valid && !ctx.inferredType && !ctx.validateOnly) {
    ctx.validateOnly = false;
    const [object, key] = ctx.getCurrentObjectAndKey();
    ctx.addTags(object, key, 'string-type-checked');
  }

  if ('maxLength' in schema && ctx.data.length > schema.maxLength) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  if ('minLength' in schema && ctx.data.length < schema.minLength) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  // *should be* a regular expression wtf that means (thanks "spec").
  if (schema.pattern) {
    const p = ctx.getRegex(schema.pattern);
    if (!ctx.data.match(p)) {
      valid = false;
    }
  }

  return valid;
}

//
// type: array
//
// eslint-disable-next-line complexity
function t_array(schema, ctx) {
  if (!Array.isArray(ctx.data)) {
    return false;
  }

  // https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-01#section-10.1
  // 6.4.1 - omitting this keyword has the same behavior as an empty schema, i.e, everything passes
  let valid = true;

  const { items } = schema;

  // ajv's array evaluation order
  // maxItems
  // minItems
  // additionalItems
  // items
  // contains
  // uniqueItems
  if ('maxItems' in schema && ctx.data.length > schema.maxItems) {
    valid = false;
    if (!ctx.allError) {
      return false;
    }
  }

  if ('minItems' in schema && ctx.data.length < schema.minItems) {
    valid = false;
    if (!ctx.allError) {
      return false;
    }
  }

  // makes little sense to me, but ajv validates additionalItems before items.
  let itemsIsArray = false;
  let max = Infinity;
  if (Array.isArray(items)) {
    itemsIsArray = true;
    max = Math.min(ctx.data.length, items.length);
  }
  if ('additionalItems' in schema && itemsIsArray && ctx.data.length > max) {
    for (let i = max; i < ctx.data.length; i++) {
      if (!ctx.evalInto(schema.additionalItems, i)) {
        if (!ctx.allErrors) {
          return false;
        }
        valid = false;
      }
    }
  }

  if (items !== undefined) {
    // if items is not an array then all elements of this.data must validate against it.
    // items must be a valid json schema.
    if (!itemsIsArray) {
      for (let i = 0; i < ctx.data.length; i++) {
        if (!ctx.evalInto(items, i)) {
          if (!ctx.allErrors) {
            return false;
          }
          valid = false;
        }
      }
    } else {
      // items is an array of schema by which the first this.data.length elements,
      // if present, are evaluated.
      for (let i = 0; i < max; i++) {
        const itemValid = ctx.evalInto(items[i], i);
        if (!itemValid && !ctx.allErrors) {
          return false;
        }
        valid = valid && itemValid;
      }
    }
  }

  // now see if the array contains an element that validates with
  // the contains schema.
  if ('contains' in schema) {
    let contains = false;
    for (let i = 0; i < ctx.data.length; i++) {
      if (ctx.evalInto(schema.contains, i)) {
        contains = true;
        break;
      }
    }
    valid = valid && contains;
    if (!valid && !ctx.allErrors) {
      return false;
    }
  }

  // must the array elements be unique?
  if (schema.uniqueItems && !elementsAreUnique(ctx.data)) {
    return false;
  }

  return valid;
}

// type: array-helper
function elementsAreUnique(data) {
  for (let i = 0; i < data.length - 1; i++) {
    for (let j = i + 1; j < data.length; j++) {
      if (fastDeepEqual(data[i], data[j])) {
        return false;
      }
    }
  }
  return true;
}

//
// type: object
//
// eslint-disable-next-line complexity
function t_object(schema, ctx) {
  if (typeof ctx.data !== 'object') {
    return false;
  }
  if (Array.isArray(ctx.data) || ctx.data === null) {
    return false;
  }

  let valid = true;
  const dProps = Object.keys(ctx.data);

  // ajv's object evaluation order
  // (determined by object test in ajv-only test)
  // maxProperties
  // minProperties
  // required
  // additionalProperties
  // properties
  // patternProperties
  if ('maxProperties' in schema && dProps.length > schema.maxProperties) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }
  if ('minProperties' in schema && dProps.length < schema.minProperties) {
    valid = false;
    if (!ctx.allErrors) {
      return false;
    }
  }

  // make sure each required property is present
  if (schema.required) {
    for (const req of schema.required) {
      if (dProps.indexOf(req) < 0) {
        valid = false;
        break;
      }
    }
    if (!valid && !ctx.allErrors) {
      return false;
    }
  }

  // now comes property name validation
  if ('propertyNames' in schema) {
    if (typeof schema.propertyNames === 'boolean') {
      if (schema.propertyNames) {
        return true;
      }
      // when propertyNames is false any property is invalid
      return Object.keys(ctx.data).length === 0;
    }
    const { data } = ctx;

    for (const name in ctx.data) {
      ctx.data = name;
      // validateOnly is reset in t_string()
      ctx.validateOnly = true;
      const nameValid = t_string(schema.propertyNames, ctx);
      if (!nameValid && !ctx.allErrors) {
        ctx.data = data;
        return false;
      }
      valid = valid && nameValid;
    }
    ctx.data = data;
  }

  // additional properties is next and that requires knowing the union of
  // properties and patternProperties. start by copying the properties; items
  // will be deleted from aProps but nothing will be modified.
  const aProps = Object.assign({}, ctx.data);
  // remove any enumerated properties from a[dditional]Props
  if (schema.properties) {
    for (const p in schema.properties) {
      delete aProps[p];
    }
  }
  // now remove any props matching a patternProperty pattern from a[dditional]Props
  const propToPatternSchemas = new Map();
  if (schema.patternProperties) {
    for (const p of dProps) {
      for (const pattern of matching(schema.patternProperties, p, ctx)) {
        // remove the matching property
        delete aProps[p];
        // and add the matching patterns to the propToPatterns map because
        // it's not yet time to validate using patternProperties, but we don't
        // want to re-evaluate all the regexes when it is time.
        let patterns = propToPatternSchemas.get(p);
        if (patterns === undefined) {
          patterns = [];
        }
        patterns.push(schema.patternProperties[pattern]);
        propToPatternSchemas.set(p, patterns);
      }
    }
  }

  // now aProps comprises only properties in this.data that are not listed in
  // properties and not matching any pattern in patternProperties, so they
  // can be evaluated against the additionalProperties schema.
  if ('additionalProperties' in schema) {
    for (const p in aProps) {
      if (!ctx.evalInto(schema.additionalProperties, p)) {
        valid = false;
        if (!ctx.allErrors) {
          return false;
        }
      }
    }
  }

  // the two forms of dependencies are validated here
  if (schema.dependencies) {
    valid = evaluateObjectDependencies(schema, ctx) && valid;
    if (!valid && !ctx.allErrors) {
      return false;
    }
  }

  // now evaluate properties against the schema for that property.
  if (schema.properties) {
    for (const p of Object.keys(schema.properties)) {
      // if the property is not present in the data then it cannot be validated
      // here. if it was required it has already been checked.
      if (!(p in ctx.data)) {
        continue;
      }
      if (!ctx.evalInto(schema.properties[p], p)) {
        valid = false;
        if (!ctx.allErrors) {
          return false;
        }
      }
    }
  }

  // finally, pattern properties can be evaluated
  for (const [p, schemas] of propToPatternSchemas) {
    for (const schema of schemas) {
      if (!ctx.evalInto(schema, p)) {
        valid = false;
        if (!ctx.allErrors) {
          return false;
        }
      }
    }
  }

  return valid;
}

//
// type: object-helper
//
/**
 * generator function to return patterns for each pattern that matches the
 * specified property name. it caches the RegExp objects generated from the
 * string-regex keys to avoid re-generating RegExp objects on subsequent
 * calls.
 *
 * @param {object} patternProps contains {[string-regex]: schema, ...}
 * @param {string} prop the property name to be checked against the regexes
 * @yields {string} pattern keys that match prop
 */
function* matching(patternProps, prop, ctx) {
  for (const stringRegex of Object.keys(patternProps)) {
    const re = ctx.getRegex(stringRegex);
    if (!re) {
      continue;
    }

    if (re.test(prop)) {
      yield stringRegex;
    }
  }
}

//
// type: object helper
//
// eslint-disable-next-line complexity
function evaluateObjectDependencies(schema, ctx) {
  const deps = schema.dependencies;
  let valid = true;

  // the schema should not have passed validation according to the spec: 6.5.7 dependencies.
  // https://datatracker.ietf.org/doc/html/draft-handrews-json-schema-validation-01
  if (typeof deps !== 'object') {
    return false;
  }

  // for each property name in the dependencies object
  for (const key in deps) {
    const dep = deps[key];

    if (Array.isArray(dep)) {
      // all elements *MUST* be strings (and must be unique, but that doesn't
      // really matter).
      if (dep.findIndex((elem) => typeof elem !== 'string') >= 0) {
        // should have failed schema validation. not sure what non-strict
        // mode will do here.
        return false;
      }
      // it's an array! if key is present in this.data then all the properties
      // enumerated in the array (they're strings) must be present in this.data.
      if (key in ctx.data && !dep.every((requiredProp) => requiredProp in ctx.data)) {
        // allErrors: true - check ajv's handling
        valid = false;
        if (!ctx.allErrors) {
          return false;
        }
      }
    } else if (typeof dep === 'object' || typeof dep === 'boolean') {
      // is it a valid schema?
      if (dep === null) {
        return false;
      }
      // it's a schema. if the dependency key is present in this.data then
      // the entire instance must validate against deps[key] (the dependency value)
      if (key in ctx.data) {
        valid = ctx.evaluate(dep) && valid;
        if (!valid && !ctx.allErrors) {
          return false;
        }
      }
      // check next key
      continue;
    }
  }

  return valid;
}

module.exports = {
  null: t_null,
  boolean: t_boolean,
  number: t_number,
  string: t_string,
  array: t_array,
  object: t_object
};
