import UriResolver from 'fast-uri';
import jsonpointer from 'jsonpointer';
import get from 'lodash/get';
import isEmpty from 'lodash/isEmpty';
import isObject from 'lodash/isObject';
import omit from 'lodash/omit';

import {
  ALL_OF_KEY,
  ID_KEY,
  JSON_SCHEMA_DRAFT_2019_09,
  JSON_SCHEMA_DRAFT_2020_12,
  REF_KEY,
  SCHEMA_KEY,
} from './constants';
import type { GenericObjectType, RJSFSchema, StrictRJSFSchema } from './types';

/** Looks for the `$id` pointed by `ref` in the schema definitions embedded in
 * a JSON Schema bundle
 *
 * @param schema - The schema wherein `ref` should be searched
 * @param ref - The `$id` of the reference to search for
 * @returns - The schema matching the reference, or `undefined` if no match is found
 */
function findEmbeddedSchemaRecursive<S extends StrictRJSFSchema = RJSFSchema>(schema: S, ref: string): S | undefined {
  if (ID_KEY in schema && UriResolver.equal(schema[ID_KEY] as string, ref)) {
    return schema;
  }
  for (const subSchema of Object.values(schema)) {
    if (Array.isArray(subSchema)) {
      for (const item of subSchema) {
        if (isObject(item)) {
          const result = findEmbeddedSchemaRecursive<S>(item as S, ref);
          if (result !== undefined) {
            return result;
          }
        }
      }
    } else if (isObject(subSchema)) {
      const result = findEmbeddedSchemaRecursive<S>(subSchema as S, ref);
      if (result !== undefined) {
        return result;
      }
    }
  }
  return undefined;
}

/** Parses a JSONSchema and makes all references absolute with respect to
 * the `baseURI` argument
 * @param schema - The schema to be processed
 * @param baseURI - The base URI to be used for resolving relative references
 */
export function makeAllReferencesAbsolute<S extends StrictRJSFSchema = RJSFSchema>(schema: S, baseURI: string): S {
  const currentURI = get(schema, ID_KEY, baseURI);
  let result = schema;
  // Make all other references absolute
  if (REF_KEY in result) {
    result = { ...result, [REF_KEY]: UriResolver.resolve(currentURI, result[REF_KEY]!) };
  }
  // Look for references in nested subschemas
  for (const [key, subSchema] of Object.entries(result)) {
    if (Array.isArray(subSchema)) {
      result = {
        ...result,
        [key]: subSchema.map((item) => (isObject(item) ? makeAllReferencesAbsolute(item as S, currentURI) : item)),
      };
    } else if (isObject(subSchema)) {
      result = { ...result, [key]: makeAllReferencesAbsolute(subSchema as S, currentURI) };
    }
  }
  return result;
}

/** Splits out the value at the `key` in `object` from the `object`, returning an array that contains in the first
 * location, the `object` minus the `key: value` and in the second location the `value`.
 *
 * @param key - The key from the object to extract
 * @param object - The object from which to extract the element
 * @returns - An array with the first value being the object minus the `key` element and the second element being the
 *      value from `object[key]`
 */
export function splitKeyElementFromObject(key: string, object: GenericObjectType) {
  const value = object[key];
  const remaining = omit(object, [key]);
  return [remaining, value];
}

/** Given the name of a `$ref` from within a schema, using the `rootSchema`, recursively look up and return the
 * sub-schema using the path provided by that reference. If `#` is not the first character of the reference, the path
 * does not exist in the schema, or the reference resolves circularly back to itself, then throw an Error.
 * Otherwise return the sub-schema. Also deals with nested `$ref`s in the sub-schema.
 *
 * @param $ref - The ref string for which the schema definition is desired
 * @param [rootSchema={}] - The root schema in which to search for the definition
 * @param recurseList - List of $refs already resolved to prevent recursion
 * @param [baseURI=rootSchema['$id']] - The base URI to be used for resolving relative references
 * @returns - The sub-schema within the `rootSchema` which matches the `$ref` if it exists
 * @throws - Error indicating that no schema for that reference could be resolved
 */
export function findSchemaDefinitionRecursive<S extends StrictRJSFSchema = RJSFSchema>(
  $ref?: string,
  rootSchema: S = {} as S,
  recurseList: string[] = [],
  baseURI: string | undefined = get(rootSchema, [ID_KEY]),
): S {
  const ref = $ref || '';
  let current: S | undefined = undefined;
  let currentBaseURI = baseURI;
  if (ref.startsWith('#')) {
    // Decode URI fragment representation.
    const decodedRef = decodeURIComponent(ref.substring(1));
    if (currentBaseURI === undefined || (ID_KEY in rootSchema && rootSchema[ID_KEY] === currentBaseURI)) {
      current = jsonpointer.get(rootSchema, decodedRef);
    } else if (rootSchema[SCHEMA_KEY] === JSON_SCHEMA_DRAFT_2020_12) {
      current = findEmbeddedSchemaRecursive<S>(rootSchema, currentBaseURI.replace(/\/$/, ''));
      if (current !== undefined) {
        current = jsonpointer.get(current, decodedRef);
      }
    }
  } else if (rootSchema[SCHEMA_KEY] === JSON_SCHEMA_DRAFT_2020_12) {
    const resolvedRef = currentBaseURI ? UriResolver.resolve(currentBaseURI, ref) : ref;
    const [refId, ...refAnchor] = resolvedRef.replace(/#\/?$/, '').split('#');
    current = findEmbeddedSchemaRecursive<S>(rootSchema, refId.replace(/\/$/, ''));
    if (current !== undefined) {
      currentBaseURI = current[ID_KEY];
      if (!isEmpty(refAnchor)) {
        current = jsonpointer.get(current, decodeURIComponent(refAnchor.join('#')));
      }
    }
  }
  if (current === undefined) {
    throw new Error(`Could not find a definition for ${$ref}.`);
  }
  const nextRef = current[REF_KEY];
  if (nextRef) {
    // Check for circular references.
    if (recurseList.includes(nextRef)) {
      if (recurseList.length === 1) {
        throw new Error(`Definition for ${$ref} is a circular reference`);
      }
      const [firstRef, ...restRefs] = recurseList;
      const circularPath = [...restRefs, ref, firstRef].join(' -> ');
      throw new Error(`Definition for ${firstRef} contains a circular reference through ${circularPath}`);
    }
    const [remaining, theRef] = splitKeyElementFromObject(REF_KEY, current);
    const subSchema = findSchemaDefinitionRecursive<S>(theRef, rootSchema, [...recurseList, ref], currentBaseURI);
    if (Object.keys(remaining).length > 0) {
      if (
        rootSchema[SCHEMA_KEY] === JSON_SCHEMA_DRAFT_2019_09 ||
        rootSchema[SCHEMA_KEY] === JSON_SCHEMA_DRAFT_2020_12
      ) {
        return { [ALL_OF_KEY]: [remaining, subSchema] } as S;
      }
      return { ...remaining, ...subSchema };
    }
    return subSchema;
  }
  return current;
}

/** Given the name of a `$ref` from within a schema, using the `rootSchema`, look up and return the sub-schema using the
 * path provided by that reference. If `#` is not the first character of the reference, the path does not exist in
 * the schema, or the reference resolves circularly back to itself, then throw an Error. Otherwise return the
 * sub-schema. Also deals with nested `$ref`s in the sub-schema.
 *
 * @param $ref - The ref string for which the schema definition is desired
 * @param [rootSchema={}] - The root schema in which to search for the definition
 * @param [baseURI=rootSchema['$id']] - The base URI to be used for resolving relative references
 * @returns - The sub-schema within the `rootSchema` which matches the `$ref` if it exists
 * @throws - Error indicating that no schema for that reference could be resolved
 */
export default function findSchemaDefinition<S extends StrictRJSFSchema = RJSFSchema>(
  $ref?: string,
  rootSchema: S = {} as S,
  baseURI: string | undefined = get(rootSchema, [ID_KEY]),
): S {
  const recurseList: string[] = [];
  return findSchemaDefinitionRecursive($ref, rootSchema, recurseList, baseURI);
}
