"use strict";

module.exports = $Ref;

const Pointer = require("./pointer");
const { InvalidPointerError, isHandledError, normalizeError } = require("./util/errors");
const { safePointerToPath, stripHash, getHash } = require("./util/url");

/**
 * This class represents a single JSON reference and its resolved value.
 *
 * @class
 */
function $Ref () {
  /**
   * The file path or URL of the referenced file.
   * This path is relative to the path of the main JSON schema file.
   *
   * This path does NOT contain document fragments (JSON pointers). It always references an ENTIRE file.
   * Use methods such as {@link $Ref#get}, {@link $Ref#resolve}, and {@link $Ref#exists} to get
   * specific JSON pointers within the file.
   *
   * @type {string}
   */
  this.path = undefined;

  /**
   * The resolved value of the JSON reference.
   * Can be any JSON type, not just objects. Unknown file types are represented as Buffers (byte arrays).
   *
   * @type {?*}
   */
  this.value = undefined;

  /**
   * The {@link $Refs} object that contains this {@link $Ref} object.
   *
   * @type {$Refs}
   */
  this.$refs = undefined;

  /**
   * Indicates the type of {@link $Ref#path} (e.g. "file", "http", etc.)
   *
   * @type {?string}
   */
  this.pathType = undefined;

  /**
   * List of all errors. Undefined if no errors.
   *
   * @type {Array<JSONParserError | ResolverError | ParserError | MissingPointerError>}
   */
  this.errors = undefined;
}

/**
 * Pushes an error to errors array.
 *
 * @param {Array<JSONParserError | JSONParserErrorGroup>} err - The error to be pushed
 * @returns {void}
 */
$Ref.prototype.addError = function (err) {
  if (this.errors === undefined) {
    this.errors = [];
  }

  const existingErrors = this.errors.map(({ footprint }) => footprint);

  // the path has been almost certainly set at this point,
  // but just in case something went wrong, normalizeError injects path if necessary
  // moreover, certain errors might point at the same spot, so filter them out to reduce noise
  if (Array.isArray(err.errors)) {
    this.errors.push(...err.errors
      .map(normalizeError)
      .filter(({ footprint }) => !existingErrors.includes(footprint)),
    );
  }
  else if (!existingErrors.includes(err.footprint)) {
    this.errors.push(normalizeError(err));
  }
};

/**
 * Determines whether the given JSON reference exists within this {@link $Ref#value}.
 *
 * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash
 * @param {$RefParserOptions} options
 * @returns {boolean}
 */
$Ref.prototype.exists = function (path, options) {
  try {
    this.resolve(path, options);
    return true;
  }
  catch (e) {
    return false;
  }
};

/**
 * Resolves the given JSON reference within this {@link $Ref#value} and returns the resolved value.
 *
 * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash
 * @param {$RefParserOptions} options
 * @returns {*} - Returns the resolved value
 */
$Ref.prototype.get = function (path, options) {
  return this.resolve(path, options).value;
};

/**
 * Resolves the given JSON reference within this {@link $Ref#value}.
 *
 * @param {string} path - The full path being resolved, optionally with a JSON pointer in the hash
 * @param {$RefParserOptions} options
 * @param {string} friendlyPath - The original user-specified path (used for error messages)
 *  @param {string} pathFromRoot - The path of `obj` from the schema root
 * @returns {Pointer | null}
 */
$Ref.prototype.resolve = function (path, options, friendlyPath, pathFromRoot) {
  let pointer = new Pointer(this, path, friendlyPath);
  try {
    return pointer.resolve(this.value, options, pathFromRoot);
  }
  catch (err) {
    if (!options || !options.continueOnError || !isHandledError(err)) {
      throw err;
    }

    if (err.path === null) {
      err.path = safePointerToPath(getHash(pathFromRoot));
    }

    if (err instanceof InvalidPointerError) {
      // this is a special case - InvalidPointerError is thrown when dereferencing external file,
      // but the issue is caused by the source file that referenced the file that undergoes dereferencing
      err.source = decodeURI(stripHash(pathFromRoot));
    }

    this.addError(err);
    return null;
  }
};

/**
 * Sets the value of a nested property within this {@link $Ref#value}.
 * If the property, or any of its parents don't exist, they will be created.
 *
 * @param {string} path - The full path of the property to set, optionally with a JSON pointer in the hash
 * @param {*} value - The value to assign
 */
$Ref.prototype.set = function (path, value) {
  let pointer = new Pointer(this, path);
  this.value = pointer.set(this.value, value);
};

/**
 * Determines whether the given value is a JSON reference.
 *
 * @param {*} value - The value to inspect
 * @returns {boolean}
 */
$Ref.is$Ref = function (value) {
  return value && typeof value === "object" && typeof value.$ref === "string" && value.$ref.length > 0;
};

/**
 * Determines whether the given value is an external JSON reference.
 *
 * @param {*} value - The value to inspect
 * @returns {boolean}
 */
$Ref.isExternal$Ref = function (value) {
  return $Ref.is$Ref(value) && value.$ref[0] !== "#";
};

/**
 * Determines whether the given value is a JSON reference, and whether it is allowed by the options.
 * For example, if it references an external file, then options.resolve.external must be true.
 *
 * @param {*} value - The value to inspect
 * @param {$RefParserOptions} options
 * @returns {boolean}
 */
$Ref.isAllowed$Ref = function (value, options) {
  if ($Ref.is$Ref(value)) {
    if (value.$ref.substr(0, 2) === "#/" || value.$ref === "#") {
      // It's a JSON Pointer reference, which is always allowed
      return true;
    }
    else if (value.$ref[0] !== "#" && (!options || options.resolve.external)) {
      // It's an external reference, which is allowed by the options
      return true;
    }
  }
};

/**
 * Determines whether the given value is a JSON reference that "extends" its resolved value.
 * That is, it has extra properties (in addition to "$ref"), so rather than simply pointing to
 * an existing value, this $ref actually creates a NEW value that is a shallow copy of the resolved
 * value, plus the extra properties.
 *
 * @example:
 *  {
 *    person: {
 *      properties: {
 *        firstName: { type: string }
 *        lastName: { type: string }
 *      }
 *    }
 *    employee: {
 *      properties: {
 *        $ref: #/person/properties
 *        salary: { type: number }
 *      }
 *    }
 *  }
 *
 *  In this example, "employee" is an extended $ref, since it extends "person" with an additional
 *  property (salary).  The result is a NEW value that looks like this:
 *
 *  {
 *    properties: {
 *      firstName: { type: string }
 *      lastName: { type: string }
 *      salary: { type: number }
 *    }
 *  }
 *
 * @param {*} value - The value to inspect
 * @returns {boolean}
 */
$Ref.isExtended$Ref = function (value) {
  return $Ref.is$Ref(value) && Object.keys(value).length > 1;
};

/**
 * Returns the resolved value of a JSON Reference.
 * If necessary, the resolved value is merged with the JSON Reference to create a new object
 *
 * @example:
 *  {
 *    person: {
 *      properties: {
 *        firstName: { type: string }
 *        lastName: { type: string }
 *      }
 *    }
 *    employee: {
 *      properties: {
 *        $ref: #/person/properties
 *        salary: { type: number }
 *      }
 *    }
 *  }
 *
 *  When "person" and "employee" are merged, you end up with the following object:
 *
 *  {
 *    properties: {
 *      firstName: { type: string }
 *      lastName: { type: string }
 *      salary: { type: number }
 *    }
 *  }
 *
 * @param {object} $ref - The JSON reference object (the one with the "$ref" property)
 * @param {*} resolvedValue - The resolved value, which can be any type
 * @returns {*} - Returns the dereferenced value
 */
$Ref.dereference = function ($ref, resolvedValue) {
  if (resolvedValue && typeof resolvedValue === "object" && $Ref.isExtended$Ref($ref)) {
    let merged = {};
    for (let key of Object.keys($ref)) {
      if (key !== "$ref") {
        merged[key] = $ref[key];
      }
    }

    for (let key of Object.keys(resolvedValue)) {
      if (!(key in merged)) {
        merged[key] = resolvedValue[key];
      }
    }

    return merged;
  }
  else {
    // Completely replace the original reference with the resolved value
    return resolvedValue;
  }
};
