import jsYaml from 'js-yaml';
import { ApiDOMStructuredError } from '@swagger-api/apidom-error';
import { url } from '@swagger-api/apidom-reference/configuration/empty';
import '../../../helpers/fetch-polyfill.node.js';
import lib from './index.js';
import { isFreelyNamed, absolutifyPointer } from '../helpers.js';
import { ACCEPT_HEADER_VALUE_FOR_DOCUMENTS } from '../../../constants.js';
const ABSOLUTE_URL_REGEXP = /^([a-z]+:\/\/|\/\/)/i;
class JSONRefError extends ApiDOMStructuredError {}
const docCache = {};
const specmapRefs = new WeakMap();
const skipResolutionTestFns = [
// OpenAPI 2.0 response examples
path =>
// ["paths", *, *, "responses", *, "examples"]
path[0] === 'paths' && path[3] === 'responses' && path[5] === 'examples',
// OpenAPI 3.0 Response Media Type Examples
path =>
// ["paths", *, *, "responses", *, "content", *, "example"]
path[0] === 'paths' && path[3] === 'responses' && path[5] === 'content' && path[7] === 'example', path =>
// ["paths", *, *, "responses", *, "content", *, "examples", *, "value"]
path[0] === 'paths' && path[3] === 'responses' && path[5] === 'content' && path[7] === 'examples' && path[9] === 'value',
// OpenAPI 3.0 Request Body Media Type Examples
path =>
// ["paths", *, *, "requestBody", "content", *, "example"]
path[0] === 'paths' && path[3] === 'requestBody' && path[4] === 'content' && path[6] === 'example', path =>
// ["paths", *, *, "requestBody", "content", *, "examples", *, "value"]
path[0] === 'paths' && path[3] === 'requestBody' && path[4] === 'content' && path[6] === 'examples' && path[8] === 'value',
// OAS 3.0 Parameter Examples
path =>
// ["paths", *, "parameters", *, "example"]
path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'example', path =>
// ["paths", *, *, "parameters", *, "example"]
path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'example', path =>
// ["paths", *, "parameters", *, "examples", *, "value"]
path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'examples' && path[6] === 'value', path =>
// ["paths", *, *, "parameters", *, "examples", *, "value"]
path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'examples' && path[7] === 'value', path =>
// ["paths", *, "parameters", *, "content", *, "example"]
path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'content' && path[6] === 'example', path =>
// ["paths", *, "parameters", *, "content", *, "examples", *, "value"]
path[0] === 'paths' && path[2] === 'parameters' && path[4] === 'content' && path[6] === 'examples' && path[8] === 'value', path =>
// ["paths", *, *, "parameters", *, "content", *, "example"]
path[0] === 'paths' && path[3] === 'parameters' && path[4] === 'content' && path[7] === 'example', path =>
// ["paths", *, *, "parameters", *, "content", *, "examples", *, "value"]
path[0] === 'paths' && path[3] === 'parameters' && path[5] === 'content' && path[7] === 'examples' && path[9] === 'value'];
const shouldSkipResolution = path => skipResolutionTestFns.some(fn => fn(path));

// =========================
// Core
// =========================

/**
 * This plugin resolves the JSON pointers.
 * A major part of this plugin deals with cyclic references via 2 mechanisms.
 * 1. If a pointer was already resolved before in this path, halt.
 * 2. If the patch value points to one of the ancestors in this path, halt.
 *
 * Note that either one of these mechanism is sufficient, both must be in place.
 * For examples:
 *
 * Given the following spec, #1 alone is insufficient because after the 2nd
 * application, there will be a cyclic object reference.
 *   a.b.c: $ref-d
 *   d.e.f: $ref-a (per #1, safe to return patch as no immediate cycle)
 *
 * Given the following spec, #2 alone is insufficient because although there will
 * never be any cyclic object reference, the plugin will keep producing patches.
 *   a: $ref-b
 *   b: $ref-a
 */
const plugin = {
  key: '$ref',
  plugin: (ref, key, fullPath, specmap) => {
    const specmapInstance = specmap.getInstance();
    const parent = fullPath.slice(0, -1);
    if (isFreelyNamed(parent) || shouldSkipResolution(parent)) {
      return undefined;
    }
    const {
      baseDoc
    } = specmap.getContext(fullPath);
    if (typeof ref !== 'string') {
      return new JSONRefError('$ref: must be a string (JSON-Ref)', {
        $ref: ref,
        baseDoc,
        fullPath
      });
    }
    const splitString = split(ref);
    const refPath = splitString[0];
    const pointer = splitString[1] || '';
    let basePath;
    try {
      basePath = baseDoc || refPath ? absoluteify(refPath, baseDoc) : null;
    } catch (e) {
      return wrapError(e, {
        pointer,
        $ref: ref,
        basePath,
        fullPath
      });
    }
    let promOrVal;
    let tokens;
    if (pointerAlreadyInPath(pointer, basePath, parent, specmap)) {
      // Cyclic reference!
      // if `useCircularStructures` is not set, just leave the reference
      // unresolved, but absolutify it so that we don't leave an invalid $ref
      // path in the content
      if (!specmapInstance.useCircularStructures) {
        const absolutifiedRef = absolutifyPointer(ref, basePath);
        if (ref === absolutifiedRef) {
          // avoids endless looping
          // without this, the ref plugin never stops seeing this $ref
          return null;
        }
        return lib.replace(fullPath, absolutifiedRef);
      }
    }
    if (basePath == null) {
      tokens = jsonPointerToArray(pointer);
      promOrVal = specmap.get(tokens);
      if (typeof promOrVal === 'undefined') {
        promOrVal = new JSONRefError(`Could not resolve reference: ${ref}`, {
          pointer,
          $ref: ref,
          baseDoc,
          fullPath
        });
      }
    } else {
      promOrVal = extractFromDoc(basePath, pointer);
      // eslint-disable-next-line no-underscore-dangle
      if (promOrVal.__value != null) {
        promOrVal = promOrVal.__value; // eslint-disable-line no-underscore-dangle
      } else {
        promOrVal = promOrVal.catch(e => {
          throw wrapError(e, {
            pointer,
            $ref: ref,
            baseDoc,
            fullPath
          });
        });
      }
    }
    if (promOrVal instanceof Error) {
      return [lib.remove(fullPath), promOrVal];
    }
    const absolutifiedRef = absolutifyPointer(ref, basePath);
    const patch = lib.replace(parent, promOrVal, {
      $$ref: absolutifiedRef
    });
    if (basePath && basePath !== baseDoc) {
      return [patch, lib.context(parent, {
        baseDoc: basePath
      })];
    }
    try {
      // prevents circular values from being constructed, unless we specifically
      // want that to happen
      if (!patchValueAlreadyInPath(specmap.state, patch) || specmapInstance.useCircularStructures) {
        return patch;
      }
    } catch (e) {
      // if we're catching here, path traversal failed, so we should
      // ditch without sending any patches back up.
      //
      // this is a narrow fix for the larger problem of patches being queued
      // and then having the state they were generated against be modified
      // before they are applied.
      //
      // TODO: re-engineer specmap patch/state management to avoid this
      return null;
    }
    return undefined;
  }
};
const mod = Object.assign(plugin, {
  docCache,
  absoluteify,
  clearCache,
  JSONRefError,
  wrapError,
  getDoc,
  split,
  extractFromDoc,
  fetchJSON,
  extract,
  jsonPointerToArray,
  unescapeJsonPointerToken
});
export default mod;

// =========================
// Utilities
// =========================

/**
 * Resolves a path and its base to an abolute URL.
 * @api public
 */
function absoluteify(path, basePath) {
  if (!ABSOLUTE_URL_REGEXP.test(path)) {
    if (!basePath) {
      throw new JSONRefError(`Tried to resolve a relative URL, without having a basePath. path: '${path}' basePath: '${basePath}'`);
    }
    return url.resolve(basePath, path);
  }
  return path;
}

/**
 * Wraps an error as JSONRefError.
 * @param  {Error} e      the error.
 * @param  {Object} extra (optional) optional data.
 * @return {Error}        an instance of JSONRefError.
 * @api public
 */
function wrapError(e, extra) {
  let message;
  if (e && e.response && e.response.body) {
    message = `${e.response.body.code} ${e.response.body.message}`;
  } else {
    message = e.message;
  }
  return new JSONRefError(`Could not resolve reference: ${message}`, {
    ...extra,
    cause: e
  });
}

/**
 * Splits a pointer by the hash delimiter.
 * @api public
 */
function split(ref) {
  return (ref + '').split('#'); // eslint-disable-line prefer-template
}

/**
 * Extracts a pointer from its document.
 * @param  {String} docPath the absolute document URL.
 * @param  {String} pointer the pointer whose value is to be extracted.
 * @return {Promise}        a promise of the pointer value.
 * @api public
 */
function extractFromDoc(docPath, pointer) {
  const doc = docCache[docPath];
  if (doc && !lib.isPromise(doc)) {
    // If doc is already available, return __value together with the promise.
    // __value is for special handling in cycle check:
    // pointerAlreadyInPath() won't work if patch.value is a promise,
    // thus when that promise is finally resolved, cycle might happen (because
    // `spec` and `docCache[basePath]` refer to the exact same object).
    // See test "should resolve a cyclic spec when baseDoc is specified".
    try {
      const v = extract(pointer, doc);
      return Object.assign(Promise.resolve(v), {
        __value: v
      });
    } catch (e) {
      return Promise.reject(e);
    }
  }
  return getDoc(docPath).then(_doc => extract(pointer, _doc));
}

/**
 * Clears all document caches.
 * @param  {String} item (optional) the name of the cache item to be cleared.
 * @api public
 */
function clearCache(item) {
  if (typeof item !== 'undefined') {
    delete docCache[item];
  } else {
    Object.keys(docCache).forEach(key => {
      delete docCache[key];
    });
  }
}

/**
 * Fetches and caches a document.
 * @param  {String} docPath the absolute URL of the document.
 * @return {Promise}        a promise of the document content.
 * @api public
 */
function getDoc(docPath) {
  const val = docCache[docPath];
  if (val) {
    return lib.isPromise(val) ? val : Promise.resolve(val);
  }

  // NOTE: we need to use `mod.fetchJSON` in order to be able to overwrite it.
  // Any tips on how to make this cleaner, please ping!
  docCache[docPath] = mod.fetchJSON(docPath).then(doc => {
    docCache[docPath] = doc;
    return doc;
  });
  return docCache[docPath];
}

/**
 * Fetches a document.
 * @param  {String} docPath the absolute URL of the document.
 * @return {Promise}        a promise of the document content.
 * @api public
 */
function fetchJSON(docPath) {
  return fetch(docPath, {
    headers: {
      Accept: ACCEPT_HEADER_VALUE_FOR_DOCUMENTS
    },
    loadSpec: true
  }).then(res => res.text()).then(text => jsYaml.load(text));
}

/**
 * Extracts a pointer from an object.
 * @param  {String[]} pointer the JSON pointer.
 * @param  {Object} obj       an object whose value is to be extracted.
 * @return {Object}           the value to be extracted.
 * @api public
 */
function extract(pointer, obj) {
  const tokens = jsonPointerToArray(pointer);
  if (tokens.length < 1) {
    return obj;
  }
  const val = lib.getIn(obj, tokens);
  if (typeof val === 'undefined') {
    throw new JSONRefError(`Could not resolve pointer: ${pointer} does not exist in document`, {
      pointer
    });
  }
  return val;
}

/**
 * Converts a JSON pointer to array.
 * @api public
 */
function jsonPointerToArray(pointer) {
  if (typeof pointer !== 'string') {
    throw new TypeError(`Expected a string, got a ${typeof pointer}`);
  }
  if (pointer[0] === '/') {
    pointer = pointer.substr(1);
  }
  if (pointer === '') {
    return [];
  }
  return pointer.split('/').map(unescapeJsonPointerToken);
}

/**
 * Unescapes a JSON pointer.
 * @api public
 */
function unescapeJsonPointerToken(token) {
  if (typeof token !== 'string') {
    return token;
  }
  const params = new URLSearchParams(`=${token.replace(/~1/g, '/').replace(/~0/g, '~')}`);
  return params.get('');
}

/**
 * Escapes a JSON pointer.
 * @api public
 */
function escapeJsonPointerToken(token) {
  const params = new URLSearchParams([['', token.replace(/~/g, '~0').replace(/\//g, '~1')]]);
  return params.toString().slice(1);
}
function arrayToJsonPointer(arr) {
  if (arr.length === 0) {
    return '';
  }
  return `/${arr.map(escapeJsonPointerToken).join('/')}`;
}
const pointerBoundaryChar = c => !c || c === '/' || c === '#';
function pointerIsAParent(pointer, parentPointer) {
  if (pointerBoundaryChar(parentPointer)) {
    // This is the root of the document, so its naturally a parent
    return true;
  }
  const nextChar = pointer.charAt(parentPointer.length);
  const lastParentChar = parentPointer.slice(-1);
  return pointer.indexOf(parentPointer) === 0 && (!nextChar || nextChar === '/' || nextChar === '#') && lastParentChar !== '#';
}

// =========================
// Private
// =========================

/**
 * Checks if this pointer points back to one or more pointers along the path.
 */
function pointerAlreadyInPath(pointer, basePath, parent, specmap) {
  let refs = specmapRefs.get(specmap);
  if (!refs) {
    // Stores all resolved references of a specmap instance.
    // Schema: path -> pointer (path's $ref value).
    refs = {};
    specmapRefs.set(specmap, refs);
  }
  const parentPointer = arrayToJsonPointer(parent);
  const fullyQualifiedPointer = `${basePath || '<specmap-base>'}#${pointer}`;

  // dirty hack to strip `allof/[index]` from the path, in order to avoid cases
  // where we get false negatives because:
  // - we resolve a path, then
  // - allOf plugin collapsed `allOf/[index]` out of the path, then
  // - we try to work on a child $ref within that collapsed path.
  //
  // because of the path collapse, we lose track of it in our specmapRefs hash
  // solution: always throw the allOf constructs out of paths we store
  // TODO: solve this with a global register, or by writing more metadata in
  // either allOf or refs plugin
  const safeParentPointer = parentPointer.replace(/allOf\/\d+\/?/g, '');

  // Case 1: direct cycle, e.g. a.b.c.$ref: '/a.b'
  // Detect by checking that the parent path doesn't start with pointer.
  // This only applies if the pointer is internal, i.e. basePath === rootPath (could be null)
  const rootDoc = specmap.contextTree.get([]).baseDoc;
  if (basePath === rootDoc && pointerIsAParent(safeParentPointer, pointer)) {
    // eslint-disable-line
    return true;
  }

  // Case 2: indirect cycle
  //  ex1: a.$ref: '/b'  &  b.c.$ref: '/b/c'
  //  ex2: a.$ref: '/b/c'  &  b.c.$ref: '/b'
  // Detect by retrieving all the $refs along the path of parent
  // and checking if any starts with pointer or vice versa.
  let currPath = '';
  const hasIndirectCycle = parent.some(token => {
    currPath = `${currPath}/${escapeJsonPointerToken(token)}`;
    return refs[currPath] && refs[currPath].some(ref => pointerIsAParent(ref, fullyQualifiedPointer) || pointerIsAParent(fullyQualifiedPointer, ref));
  });
  if (hasIndirectCycle) {
    return true;
  }

  // No cycle, this ref will be resolved, so stores it now for future detection.
  // No need to store if has cycle, as parent path is a dead-end and won't be checked again.

  refs[safeParentPointer] = (refs[safeParentPointer] || []).concat(fullyQualifiedPointer);
  return undefined;
}

/**
 * Checks if the value of this patch ends up pointing to an ancestor along the path.
 */
function patchValueAlreadyInPath(root, patch) {
  const ancestors = [root];
  patch.path.reduce((parent, p) => {
    ancestors.push(parent[p]);
    return parent[p];
  }, root);
  return pointToAncestor(patch.value);
  function pointToAncestor(obj) {
    return lib.isObject(obj) && (ancestors.indexOf(obj) >= 0 || Object.keys(obj).some(k => pointToAncestor(obj[k])));
  }
}