/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this file,
 * You can obtain one at http://mozilla.org/MPL/2.0/. */

/* This file implements a not-quite standard JSON schema validator. It differs
 * from the spec in a few ways:
 *
 *  - the spec doesn't allow custom types to be defined, but this validator
 *    defines "URL", "URLorEmpty", "origin" etc.
 * - Strings are automatically converted to `URL` objects for the appropriate
 *   types.
 * - It doesn't support "pattern" when matching strings.
 * - The boolean type accepts (and casts) 0 and 1 as valid values.
 */

const lazy = {};

ChromeUtils.defineLazyGetter(lazy, "log", () => {
  let { ConsoleAPI } = ChromeUtils.importESModule(
    "resource://gre/modules/Console.sys.mjs"
  );
  return new ConsoleAPI({
    prefix: "JsonSchemaValidator",
    // tip: set maxLogLevel to "debug" and use log.debug() to create detailed
    // messages during development. See LOG_LEVELS in Console.sys.mjs for details.
    maxLogLevel: "error",
  });
});

/**
 * @typedef {object} JsonSchemaValidatorResult
 * @property {boolean} valid
 *   True if validation is successful, false if not.
 * @property {any} parsedValue
 *   If validation is successful, this is the validated value.  It can
 *   differ from the passed-in value in the following ways:
 *
 *   - If a type in the schema is "URL" or "URLorEmpty", the passed-in value can
 *     use a string instead and it will be converted into a `URL` object in
 *     parsedValue.
 *   - Some of the `allow*` parameters control the properties that appear.
 *     {@link JsonSchemaValidator.validate()}.
 *  @property {JsonSchemaValidatorError} error
 */

/**
 * To validate a single value, use the static `JsonSchemaValidator.validate`
 * method.  If you need to validate multiple values, you instead might want to
 * make a JsonSchemaValidator instance with the options you need and then call
 * the `validate` instance method.
 */
export class JsonSchemaValidator {
  /**
   * Validates a value against a schema.
   *
   * @param {any} value
   *   The value to validate.
   * @param {object} schema
   *   The schema to validate against.
   * @param {object} [options]
   * @param {boolean} [options.allowArrayNonMatchingItems]
   *   When true:
   *     Invalid items in arrays will be ignored, and they won't be included in
   *     result.parsedValue.
   *   When false:
   *     Invalid items in arrays will cause validation to fail.
   * @param {boolean} [options.allowExplicitUndefinedProperties]
   *   When true:
   *     `someProperty: undefined` will be allowed for non-required properties.
   *   When false:
   *     `someProperty: undefined` will cause validation to fail even for
   *     properties that are not required.
   * @param {boolean} [options.allowNullAsUndefinedProperties]
   *   When true:
   *     `someProperty: null` will be allowed for non-required properties whose
   *     expected types are non-null.
   *   When false:
   *     `someProperty: null` will cause validation to fail for non-required
   *     properties, except for properties whose expected types are null.
   * @param {boolean} [options.allowAdditionalProperties]
   *   When true:
   *     Properties that are not defined in the schema will be ignored, and they
   *     won't be included in result.parsedValue.
   *   When false:
   *     Properties that are not defined in the schema will cause validation to
   *     fail.
   *   Note: Schema objects of type "object" can also contain a boolean property
   *     called `additionalProperties` that functions as a local version of this
   *     param. When true, extra properties will be allowed in the corresponding
   *     input objects regardless of `allowAdditionalProperties`, and as with
   *     `allowAdditionalProperties`, extra properties won't be included in
   *     `result.parsedValue`. (The inverse is not true: If a schema object
   *     defines `additionalProperties: false` but `allowAdditionalProperties`
   *     is true, extra properties will be allowed.)
   * @returns {JsonSchemaValidatorResult}
   */
  static validate(
    value,
    schema,
    {
      allowArrayNonMatchingItems = false,
      allowExplicitUndefinedProperties = false,
      allowNullAsUndefinedProperties = false,
      allowAdditionalProperties = false,
    } = {}
  ) {
    let validator = new JsonSchemaValidator({
      allowArrayNonMatchingItems,
      allowExplicitUndefinedProperties,
      allowNullAsUndefinedProperties,
      allowAdditionalProperties,
    });
    return validator.validate(value, schema);
  }

  /**
   * Constructor.
   *
   * @param {object} [options]
   * @param {boolean} [options.allowArrayNonMatchingItems]
   *   See the static `validate` method above.
   * @param {boolean} [options.allowExplicitUndefinedProperties]
   *   See the static `validate` method above.
   * @param {boolean} [options.allowNullAsUndefinedProperties]
   *   See the static `validate` method above.
   * @param {boolean} [options.allowAdditionalProperties]
   *   See the static `validate` method above.
   */
  constructor({
    allowArrayNonMatchingItems = false,
    allowExplicitUndefinedProperties = false,
    allowNullAsUndefinedProperties = false,
    allowAdditionalProperties = false,
  } = {}) {
    this.allowArrayNonMatchingItems = allowArrayNonMatchingItems;
    this.allowExplicitUndefinedProperties = allowExplicitUndefinedProperties;
    this.allowNullAsUndefinedProperties = allowNullAsUndefinedProperties;
    this.allowAdditionalProperties = allowAdditionalProperties;
  }

  /**
   * Validates a value against a schema.
   *
   * @param {*} value
   *   The value to validate.
   * @param {object} schema
   *   The schema to validate against.
   * @return {object}
   *   The result object.  See the static `validate` method above.
   */
  validate(value, schema) {
    return this._validateRecursive(value, schema, [], {
      rootValue: value,
      rootSchema: schema,
    });
  }

  // eslint-disable-next-line complexity
  _validateRecursive(param, properties, keyPath, state) {
    lazy.log.debug(`checking @${param}@ for type ${properties.type}`);

    if (Array.isArray(properties.type)) {
      lazy.log.debug("type is an array");
      // For an array of types, the value is valid if it matches any of the
      // listed types. To check this, make versions of the object definition
      // that include only one type at a time, and check the value against each
      // one.
      for (const type of properties.type) {
        let typeProperties = Object.assign({}, properties, { type });
        lazy.log.debug(`checking subtype ${type}`);
        let result = this._validateRecursive(
          param,
          typeProperties,
          keyPath,
          state
        );
        if (result.valid) {
          return result;
        }
      }
      // None of the types matched
      return {
        valid: false,
        error: new JsonSchemaValidatorError({
          message:
            `The value '${valueToString(param)}' does not match any type in ` +
            valueToString(properties.type),
          invalidValue: param,
          keyPath,
          state,
        }),
      };
    }

    switch (properties.type) {
      case "boolean":
      case "number":
      case "integer":
      case "string":
      case "URL":
      case "URLorEmpty":
      case "origin":
      case "null": {
        let result = this._validateSimpleParam(
          param,
          properties.type,
          keyPath,
          state
        );
        if (!result.valid) {
          return result;
        }
        if (properties.enum && typeof result.parsedValue !== "boolean") {
          if (!properties.enum.includes(param)) {
            return {
              valid: false,
              error: new JsonSchemaValidatorError({
                message:
                  `The value '${valueToString(param)}' is not one of the ` +
                  `enumerated values ${valueToString(properties.enum)}`,
                invalidValue: param,
                keyPath,
                state,
              }),
            };
          }
        }
        return result;
      }

      case "array": {
        if (!Array.isArray(param)) {
          return {
            valid: false,
            error: new JsonSchemaValidatorError({
              message:
                `The value '${valueToString(param)}' does not match the ` +
                `expected type 'array'`,
              invalidValue: param,
              keyPath,
              state,
            }),
          };
        }

        let parsedArray = [];
        for (let i = 0; i < param.length; i++) {
          let item = param[i];
          lazy.log.debug(
            `in array, checking @${item}@ for type ${properties.items.type}`
          );
          let result = this._validateRecursive(
            item,
            properties.items,
            keyPath.concat(i),
            state
          );
          if (!result.valid) {
            if (
              ("strict" in properties && properties.strict) ||
              (!("strict" in properties) && !this.allowArrayNonMatchingItems)
            ) {
              return result;
            }
            continue;
          }

          parsedArray.push(result.parsedValue);
        }

        return { valid: true, parsedValue: parsedArray };
      }

      case "object": {
        if (typeof param != "object" || !param) {
          return {
            valid: false,
            error: new JsonSchemaValidatorError({
              message:
                `The value '${valueToString(param)}' does not match the ` +
                `expected type 'object'`,
              invalidValue: param,
              keyPath,
              state,
            }),
          };
        }

        let parsedObj = {};
        let patternProperties = [];
        if ("patternProperties" in properties) {
          for (let prop of Object.keys(properties.patternProperties || {})) {
            let pattern;
            try {
              pattern = new RegExp(prop);
            } catch (e) {
              throw new Error(
                `Internal error: Invalid property pattern ${prop}`
              );
            }
            patternProperties.push({
              pattern,
              schema: properties.patternProperties[prop],
            });
          }
        }

        if (properties.required) {
          for (let required of properties.required) {
            if (!(required in param)) {
              lazy.log.error(`Object is missing required property ${required}`);
              return {
                valid: false,
                error: new JsonSchemaValidatorError({
                  message: `Object is missing required property '${required}'`,
                  invalidValue: param,
                  keyPath,
                  state,
                }),
              };
            }
          }
        }

        for (let item of Object.keys(param)) {
          let schema;
          if (
            "properties" in properties &&
            properties.properties.hasOwnProperty(item)
          ) {
            schema = properties.properties[item];
          } else if (patternProperties.length) {
            for (let patternProperty of patternProperties) {
              if (patternProperty.pattern.test(item)) {
                schema = patternProperty.schema;
                break;
              }
            }
          }
          if (!schema) {
            let allowAdditionalProperties =
              properties.additionalProperties ||
              (!properties.strict && this.allowAdditionalProperties);
            if (allowAdditionalProperties) {
              continue;
            }
            return {
              valid: false,
              error: new JsonSchemaValidatorError({
                message: `Object has unexpected property '${item}'`,
                invalidValue: param,
                keyPath,
                state,
              }),
            };
          }
          let allowExplicitUndefinedProperties =
            !properties.strict && this.allowExplicitUndefinedProperties;
          let allowNullAsUndefinedProperties =
            !properties.strict && this.allowNullAsUndefinedProperties;
          let isUndefined =
            (!allowExplicitUndefinedProperties && !(item in param)) ||
            (allowExplicitUndefinedProperties && param[item] === undefined) ||
            (allowNullAsUndefinedProperties && param[item] === null);
          if (isUndefined) {
            continue;
          }
          let result = this._validateRecursive(
            param[item],
            schema,
            keyPath.concat(item),
            state
          );
          if (!result.valid) {
            return result;
          }
          parsedObj[item] = result.parsedValue;
        }
        return { valid: true, parsedValue: parsedObj };
      }

      case "JSON":
        if (typeof param == "object") {
          return { valid: true, parsedValue: param };
        }
        try {
          let json = JSON.parse(param);
          if (typeof json != "object") {
            return {
              valid: false,
              error: new JsonSchemaValidatorError({
                message: `JSON was not an object: ${valueToString(param)}`,
                invalidValue: param,
                keyPath,
                state,
              }),
            };
          }
          return { valid: true, parsedValue: json };
        } catch (e) {
          lazy.log.error("JSON string couldn't be parsed");
          return {
            valid: false,
            error: new JsonSchemaValidatorError({
              message: `JSON string could not be parsed: ${valueToString(
                param
              )}`,
              invalidValue: param,
              keyPath,
              state,
            }),
          };
        }
    }

    return {
      valid: false,
      error: new JsonSchemaValidatorError({
        message: `Invalid schema property type: ${valueToString(
          properties.type
        )}`,
        invalidValue: param,
        keyPath,
        state,
      }),
    };
  }

  _validateSimpleParam(param, type, keyPath, state) {
    let valid = false;
    let parsedParam = param;
    let error = undefined;

    switch (type) {
      case "boolean":
        if (typeof param == "boolean") {
          valid = true;
        } else if (typeof param == "number" && (param == 0 || param == 1)) {
          valid = true;
          parsedParam = !!param;
        }
        break;

      case "number":
      case "string":
        valid = typeof param == type;
        break;

      // integer is an alias to "number" that some JSON schema tools use
      case "integer":
        valid = typeof param == "number";
        break;

      case "null":
        valid = param === null;
        break;

      case "origin":
        if (typeof param != "string") {
          break;
        }

        try {
          parsedParam = new URL(param);

          if (parsedParam.protocol == "file:") {
            // Treat the entire file URL as an origin.
            // Note this is stricter than the current Firefox policy,
            // but consistent with Chrome.
            // See https://bugzilla.mozilla.org/show_bug.cgi?id=803143
            valid = true;
          } else {
            let pathQueryRef = parsedParam.pathname + parsedParam.hash;
            // Make sure that "origin" types won't accept full URLs.
            if (pathQueryRef != "/" && pathQueryRef != "") {
              lazy.log.error(
                `Ignoring parameter "${param}" - origin was expected but received full URL.`
              );
              valid = false;
            } else {
              valid = true;
            }
          }
        } catch (ex) {
          lazy.log.error(`Ignoring parameter "${param}" - not a valid origin.`);
          valid = false;
        }
        break;

      case "URL":
      case "URLorEmpty":
        if (typeof param != "string") {
          break;
        }

        if (type == "URLorEmpty" && param === "") {
          valid = true;
          break;
        }

        try {
          parsedParam = new URL(param);
          valid = true;
        } catch (ex) {
          if (!param.startsWith("http")) {
            lazy.log.error(
              `Ignoring parameter "${param}" - scheme (http or https) must be specified.`
            );
          }
          valid = false;
        }
        break;
    }

    if (!valid && !error) {
      error = new JsonSchemaValidatorError({
        message:
          `The value '${valueToString(param)}' does not match the expected ` +
          `type '${type}'`,
        invalidValue: param,
        keyPath,
        state,
      });
    }

    let result = {
      valid,
      parsedValue: parsedParam,
    };
    if (error) {
      result.error = error;
    }
    return result;
  }
}

function valueToString(value) {
  try {
    return JSON.stringify(value);
  } catch (ex) {}
  return String(value);
}

/**
 * Class to describe an error in the JSON Schema Validator.
 */
class JsonSchemaValidatorError extends Error {
  /**
   * @type {any}
   *   The value being validated.
   */
  rootValue;

  /**
   * @type {any}
   *   The schema being checked against.
   */
  rootSchema;

  /**
   * @type {any}
   *   The value that caused validation to fail. If the passed-in value is a
   *   scalar type, this will be the value itself. If the value is an object
   *   or array, it will be the specific nested value in the object or array
   *   that caused validation to fail.
   */
  invalidValue;

  /**
   * @type {(string|number)[]}
   *   If the passed-in value is an object or array, this will contain the
   *   names of the object properties or array indexes where invalidValue can
   *   be found.  For example, assume the passed-in value is:
   *     { foo: { bar: { baz: 123 }}}
   *   And assume `baz` should be a string instead of a number.  Then
   *   invalidValue will be 123, and invalidPropertyNameComponents will be
   *   ["foo", "bar", "baz"], indicating that the erroneous property in the
   *   passed-in object is `foo.bar.baz`.
   */
  invalidPropertyNameComponents;

  /**
   * @param {object} details
   * @param {string} details.message
   *   The validation failure message.
   * @param {any} details.invalidValue
   *   The value that caused validation to fail.  If the passed-in value is a
   *   scalar type, this will be the value itself.  If the value is an object
   *   or array, it will be the specific nested value in the object or array
   *   that caused validation to fail.
   * @param {(string|number)[]} details.keyPath
   * @param {object} details.state
   *   The root value and schema of the validation.
   */
  constructor({ message, invalidValue, keyPath, state }) {
    if (keyPath.length) {
      message +=
        ". " +
        `The invalid value is property '${keyPath.join(".")}' in ` +
        JSON.stringify(state.rootValue);
    }
    super(message);
    this.name = "JsonSchemaValidatorError";
    this.rootValue = state.rootValue;
    this.rootSchema = state.rootSchema;
    this.invalidPropertyNameComponents = keyPath;
    this.invalidValue = invalidValue;
  }
}
