/**
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 url = require('url');
const { objectWalk, objectOnlyWalk } = require('./object-walk');

/**
 * use ajv.getSchema to fetch a schema given a reference to the schema.
 * the reference can be
 * - uri-ref (from $id)
 * - uri-ref#/json-pointer
 * - #/json-pointer
 * https://cswr.github.io/JsonSchema/spec/definitions_references/
 * best ref i've seen:
 * https://json-schema.org/draft/2020-12/json-schema-core.html#ref
 */

// any truly remote reference that was required should have been fetched by ajv
// in order to validate, so using ajv.getSchema() should fetch from cache.
//
// consider:
// addressSchema defines {$defs: {street: {...}}}
// addressSchema references it: {streetAddress: {$ref: '#/$defs/street/}}
// userSchema has a reference to addressSchema: {address: {$ref: 'addressSchema#/streetAddress}}
//
// when userSchema references addressSchema#/streetAddress it loads {$ref: '#/$defs/street'}.
// when userSchema sees '#/$defs/street' it tries to dereference it as a *local* reference
// because it has lost the addressSchema context.
//
// eslint-disable-next-line complexity
function evalInRefSchema(ref) {
  const ctx = this;
  const ix = ref.indexOf('#');

  // if there is a hash that's not in the first position. try
  // to find the reference.
  if (ix >= 1) {
    const [schemaId, schema, hash] = findHashRef(ref, ctx);
    if (schemaId === false) {
      ctx.logger.trace(`could not fetch ref: ${schema || ref}`);
      return false;
    }
    let newSchema;
    if (!hash || hash === '#') {
      newSchema = schema;
    } else {
      newSchema = decodeAndWalkRef(schema, hash);
      if (newSchema === undefined) {
        ctx.logger.trace(`can't walk ${ref} in schema ${schemaId}`);
        return false;
      }
    }

    // evaluate in the context of newSchema.
    ctx.schemaStackPush(schemaId, schema);
    const valid = ctx.evaluate(newSchema);
    ctx.schemaStackPop();
    return valid;
  }

  // if there is a hash in the first position then it should be a relative
  // reference to the current schema. try to find it.
  if (ix === 0) {
    // it is a relative reference to the current schema
    const curSchemaName = ctx.schemaStack[ctx.curSchemaIx];
    let { schema } = ctx.schemaMap.get(curSchemaName);
    // might need to deal with anchors. not sure.
    if (!schema) {
      ctx.logger.trace(`${curSchemaName} not in map for ref ${ref}`);
      return false;
    }
    if (ref !== '#') {
      schema = decodeAndWalkRef(schema, ref);
      if (schema === undefined) {
        ctx.logger.trace(`can't walk ${ref} in schema ${curSchemaName}`);
        return false;
      }
    }

    return ctx.evaluate(schema);
  }

  // it's not a hash reference, but it's a reference to something. it would be
  // nice if the draft-07 spec had more than one occurrence of $ref in it. and
  // if it actually said something about refs.
  const [schemaId, schema] = findNoHashRef(ref, ctx);

  if (schemaId === false) {
    ctx.logger.trace(`could not fetch ref: ${schema || ref}`);
    return false;
  }

  ctx.schemaStackPush(schemaId, schema);
  const valid = ctx.evaluate(schema);
  ctx.schemaStackPop();
  return valid;
}

//
// find the ref when there is a hash but it is not in the first
// position.
//
function findHashRef(ref, ctx) {
  // there is text before the hash. it might be a reference to the current schema.
  // it might be a new root ref on a remote ref. it might be an id. thanks for the
  // guidance from the spec we kind of have to guess. this handles it the way ajv
  // handles it, or tries to do so.
  const hashIx = ref.indexOf('#');
  const pathOrId = ref.slice(0, hashIx);
  const hash = ref.slice(hashIx);

  // maybe it's an id#/objectpath
  let vfunc = ctx.shadow.ajv.getSchema(pathOrId);
  if (vfunc && vfunc.schema.$id === pathOrId) {
    return [pathOrId, vfunc.schema, hash];
  }

  // maybe it's a ref like http://host/some.file
  // try to fetch the whole thing as it is.
  vfunc = ctx.shadow.ajv.getSchema(ref);
  if (vfunc) {
    // either use the $id for the map or null. it really should have an id.
    const schemaId = vfunc.schema.$id || '';
    ctx.addSchemaToMap(schemaId, vfunc.schema);

    return [schemaId, vfunc.schema];
  }

  // now see if the base schema is a URL.
  const curSchemaName = ctx.schemaStack[ctx.curSchemaIx];
  const mapEntry = ctx.schemaMap.get(curSchemaName);
  // if the base reference is not a URL then we're done. see if we
  // can change paths from the base URL
  if (!mapEntry || !mapEntry.url) {
    return [false];
  }
  const { url: schemaUrl } = mapEntry;

  const newRef = `${schemaUrl.origin}/${pathOrId}`;
  vfunc = ctx.shadow.ajv.getSchema(newRef);
  if (!vfunc) {
    return [false, newRef];
  }

  return [newRef, vfunc.schema, hash];
}

//
// find the reference when there is no hash in it.
//
function findNoHashRef(ref, ctx) {
  // maybe it's just an id?
  let schemaId = ref;
  let vfunc = ctx.shadow.ajv.getSchema(ref);
  if (vfunc) {
    return [schemaId, vfunc.schema];
  }

  // if the ref can't be fetched as it is then see if it might be a path on a
  // base url of the existing context. there is no place in the spec that indicates
  // this is acceptable and other packages (https://swagger.io/docs/specification/using-ref/)
  // see it differently. but this is ajv's world on top of the "flexible" spec, so
  // here goes.
  const curSchemaName = ctx.schemaStack[ctx.curSchemaIx];
  const mapEntry = ctx.schemaMap.get(curSchemaName);
  // if the base reference is not a URL then we're done.
  if (!mapEntry) {
    return [false];
  }
  const { schema, url: schemaUrl } = mapEntry;

  //
  // maybe it's ajv-tests/draft7/ref.json: location-independent identifer with
  // absolute URI or base URI change, e.g.,
  // $ref and $id both equal to "http://localhost:1234/bar#foo"
  // or
  // $id = "http://localhost:1234/root"
  // $ref = "http://localhost:1234/nested.json#foo"
  // data includes: "A": {"$id": "nested.json", "definitions": {"B": {"$id": "#foo", "type": "integer"}}}
  //
  // yes, really.

  // try simple path replacement first.
  const urlParts = url.parse(ref);
  if (schemaUrl && !urlParts.host && urlParts.path === ref) {
    const newRef = `${schemaUrl.origin}/${ref}`;
    vfunc = ctx.shadow.ajv.getSchema(newRef);
    if (vfunc) {
      schemaId = newRef;
      return [schemaId, vfunc.schema];
    }
  }

  // maybe it's a base URI change (possibly remote ajv-tests/refRemote.json: base URI change of some sort)
  for (const stack of objectOnlyWalk(schema)) {
    if (stack[stack.length - 1].$ref === ref) {
      let newRef = stack.filter((object) => object.$id);
      newRef = newRef.map((object) => {
        const id = object.$id;
        if (id === curSchemaName && schemaUrl) {
          return `${schemaUrl.origin}/`;
        }
        return id;
      });
      newRef = `${newRef.join('')}${stack[stack.length - 1].$ref}`;
      vfunc = ctx.shadow.ajv.getSchema(newRef);
      if (vfunc) {
        return [newRef, vfunc.schema];
      }
    }
  }

  return [false];
}

//
// find a reference in the specified schema.
//
function decodeAndWalkRef(schema, ref) {
  const parts = ref.split('/');
  if (parts.length === 1) {
    // location-independent identifier?
    for (const [object] of objectWalk(schema)) {
      if (object.$id === ref) {
        return object;
      }
    }
    // false is a valid schema, so return undefined when the path
    // cannot be walked.
    return undefined;
  }
  // decode embedded slash and dash while walking the schema
  for (let i = 1; i < parts.length; i++) {
    while (parts[i].indexOf('~1') >= 0) {
      parts[i] = parts[i].replace('~1', '/');
    }
    while (parts[i].indexOf('~0') >= 0) {
      parts[i] = parts[i].replace('~0', '~');
    }
    let m;
    while ((m = /%(\d{2})/.exec(parts[i]))) {
      parts[i] = parts[i].replace(m[0], String.fromCharCode(`0x${m[1]}`));
    }
    if (!(parts[i] in schema)) {
      return undefined;
    }
    schema = schema[parts[i]];
  }
  return schema;
}

module.exports = { evalInRefSchema };
