import { Injectable, isDevMode } from '@angular/core';

import { regexes } from './regexes';
import { DataTypeEncoder } from './dataTypeEncoder';
import { matchAll } from '../polyfills/matchAll';


/**
 * A parser that can evaluate Javascript data types from strings and turn them into live variables
 */
@Injectable()
export class DataTypeParser {

  constructor(private dataTypeEncoder: DataTypeEncoder) {
  }

  /**
   * Takes a string containing a Javascript data type as it would in code, such a number ('15'), a string ('"hello"'),
   * an array ('[1,2,3]'), an object ('{prop: "something"}') etc., and evaluates it to be an an actual variable.
   *
   * Note: This function works without invoking eval() and instead uses JSON.parse() for the heavy lifting. As such, it should be safe
   * to use and should cover most forms of input.
   *
   * @param dataTypeString - The string to parse
   * @param context - (optional) A context object to load variables from
   * @param event - (optional) An event object to place $event vars with
   * @param unescapeStrings - (optional) Whether to unescape strings or not
   * @param trackContextVariables - (optional) An object that will be filled out with all found context vars
   * @param allowContextFunctionCalls - (optional) Whether to allow function calls in context vars
   */
  evaluate(dataTypeString: string, context: any = {}, event?: any, unescapeStrings: boolean = true, trackContextVariables: any = {}, allowContextFunctionCalls: boolean = true): any {

    // a) Simple types
    // --------------------
    // null or undefined
    if (dataTypeString === 'null') { return null; }
    if (dataTypeString === 'undefined') { return undefined; }
    // boolean
    if (dataTypeString === 'true') { return true; }
    if (dataTypeString === 'false') { return false; }
    // number
    if (!isNaN(dataTypeString as any))  { return parseInt(dataTypeString, 10); }
    // string
    if (
      (dataTypeString.startsWith('"') && dataTypeString.endsWith('"')) ||
      (dataTypeString.startsWith("'") && dataTypeString.endsWith("'")) ||
      (dataTypeString.startsWith("`") && dataTypeString.endsWith("`"))
    ) {
      // Remove outer quotes, potentially unescape and return
      let decodedString = dataTypeString.substr(1, dataTypeString.length - 2);
      decodedString = unescapeStrings ? this.dataTypeEncoder.stripSlashes(decodedString) : decodedString;
      return decodedString;
    }

    // b) Complex types
    // --------------------
    // IMPORTANT: To properly parse complex object structures as well as context variables with regex, the string needs to be prepared. This means:
    // 1. Substrings must be rendered 'harmless', meaning all special characters that regex might confuse with variable syntax must be encoded.
    // 2. The brackets of subfunctions (e.g. context.fn(otherFn(param)).var), must be encoded as well. Regex can't handle nested substructures and wouldn't know which bracket closes the outer function.
    // 3. The brackets of subbrackets (e.g. context[context['something']].var) must be encoded for the same reason.
    dataTypeString = this.encodeDataTypeString(dataTypeString);

    // array or object literal
    if (
      (dataTypeString.startsWith('{') && dataTypeString.endsWith('}')) ||
      (dataTypeString.startsWith('[') && dataTypeString.endsWith(']'))
    ) {
      // Prepare string and parse as JSON
      const json = this.parseAsJSON(dataTypeString, unescapeStrings);
      // Load variables
      return this.loadVariables(json, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);
    }

    // event variable name
    if (dataTypeString === '$event') {
      return event;
    }

    // context variable name
    if (dataTypeString.match(new RegExp('^\\s*' + regexes.contextVariableRegex + '\\s*$', 'gm'))) {
      return this.safelyLoadContextVariable(dataTypeString, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);
    }

    throw Error('Data type for following input was not recognized and could not be parsed: "' + dataTypeString + '"');
  }

  /**
   * Encodes a data type string
   *
   * @param dataTypeString - The string to encode
   */
  encodeDataTypeString(dataTypeString: string): string {
    dataTypeString = this.dataTypeEncoder.encodeSubstrings(dataTypeString);              // Encode all potential substrings
    dataTypeString = this.dataTypeEncoder.encodeSubfunctions(dataTypeString);            // Encode all potential subfunctions
    dataTypeString = this.dataTypeEncoder.encodeVariableSubbrackets(dataTypeString);     // Encode all potential subbrackets of variables
    return dataTypeString;
  }

  /**
   * Decodes a data type string
   *
   * @param dataTypeString - The string to decode
   */
  decodeDataTypeString(dataTypeString: string): string {
    dataTypeString = this.dataTypeEncoder.decodeStringSpecialChars(dataTypeString);     // Decode special chars from substrings
    dataTypeString = this.dataTypeEncoder.decodeFunctionBrackets(dataTypeString);       // Decode subfunctions
    dataTypeString = this.dataTypeEncoder.decodeVariableBrackets(dataTypeString);       // Decode subbrackets
    dataTypeString = dataTypeString.trim();                                             // Trim whitespace
    return dataTypeString;
  }

  /**
   * In order to successfully parse a data type string with JSON.parse(), it needs to follow certain formatting rules.
   * This functions ensures that these are followed and corrects the input if not.
   *
   * @param JSONString - The string to be given to JSON.parse()
   * @param unescapeStrings - Whether to unescape the strings of this JSON
   */
  parseAsJSON(JSONString: string, unescapeStrings: boolean = true): any {

    // Find all single- and grave-quote-delimited strings and convert them to double quote strings
    const singleQuoteStringRegex = /\'(\\.|[^\'])*?\'/gm;
    JSONString = JSONString.replace(singleQuoteStringRegex, match => {
      return '"' + match.slice(1, -1) + '"';
    });
    const graveQuoteStringRegex = /\`(\\.|[^\`])*?\`/gm;
    JSONString = JSONString.replace(graveQuoteStringRegex, match => {
      return '"' + match.slice(1, -1) + '"';
    });

    // Add double-quotes around JSON property names where still missing
    const JSONPropertyRegex = /"?([a-z0-9A-Z_]+)"?\s*:/g;
    JSONString = JSONString.replace(JSONPropertyRegex, '"$1": ');

    // Prevent setting protected properties
    if (JSONString.match(/"?__proto__"?\s*:/g)) {
      throw Error('Setting the "__proto__" property in a hook input object is not allowed.');
    }
    if (JSONString.match(/"?prototype"?\s*:/g)) {
      throw Error('Setting the "prototype" property in a hook input object is not allowed.');
    }
    if (JSONString.match(/"?constructor"?\s*:/g)) {
      throw Error('Setting the "constructor" property in a hook input object is not allowed.');
    }

    // Replace undefined with null
    JSONString = this.replaceValuesInJSONString(JSONString, 'undefined', match => 'null');

    // Replace context vars with string placeholders
    JSONString = this.replaceValuesInJSONString(JSONString, regexes.contextVariableRegex, (match) => {
      return '"' + this.dataTypeEncoder.transformContextVarIntoPlacerholder(match) + '"';
    });

    // Replace $event with string placeholders
    JSONString = this.replaceValuesInJSONString(JSONString, '\\$event', match => '"__EVENT__"');

    // PARSE
    const json = JSON.parse(JSONString);

    // Decode all strings that are not context vars or the event object
    this.decodeJSONStrings(json, unescapeStrings);

    return json;
  }

  /**
   * Given a stringified json and a json value regex, allows you to replace all occurences
   * of those values in the json via a callback function.
   *
   * IMPORTANT: JSONString must be already encoded via this.encodeDataTypeString() for this to work.
   *
   * @param JSONString - The stringified JSON
   * @param valueRegex - The values to find
   * @param callbackFn - A callback fn that returns what you want to replace them with
   */
  replaceValuesInJSONString(JSONString: string, valueRegex: string, callbackFn: (match: string) => string): string {
    // With lookbehinds (too new for some browsers)
    const withLookBehindsRegex = '(?:' +
      '(?<=:\\s*)' + valueRegex + '(?=\\s*[,}])' + '|' +
      '(?<=[\\[,]\\s*)' + valueRegex + '(?=\\s*[\\],])' +
    ')';

    // Without lookbehinds (make sure to keep the lookaheads, though. This way, the same comma can be the end of one regex and the beginning of the next)
    const regex = '(?:' +
      '(:\\s*)(' + valueRegex + ')(?=\\s*[,}])' + '|' +    // Value in object: ':' followed by value followed by ',' or '}'
      '([\\[,]\\s*)(' + valueRegex + ')(?=\\s*[\\],])' +   // Value in array: '[' or ',' followed by value followed by ',' or ']'
    ')';

    return JSONString.replace(new RegExp(regex, 'gm'), (full, p1, p2, p3, p4) => {
      const startPart = p1 ? p1 : p3;
      const value = p2 ? p2 : p4;
      return startPart + callbackFn(value);
    });
  }

  /**
   * Decodes all 'normal' strings without special meaning in a JSON-like object
   *
   * @param jsonLevel - The current level of parsing
   * @param unescapeStrings - Whether to unescape the decoded strings as well
   */
  decodeJSONStrings(jsonLevel: any, unescapeStrings: boolean = true): void {
    for (const prop in jsonLevel) {
      if (typeof jsonLevel[prop] === 'string') {
        // Ignore var placeholders
        if (jsonLevel[prop] === '__EVENT__"' || jsonLevel[prop].match(new RegExp('^\\s*' + regexes.placeholderContextVariableRegex + '\\s*$', 'gm'))) {
          continue;
        }
        // Otherwise decode string
        let decodedString = this.decodeDataTypeString(jsonLevel[prop]);
        decodedString = unescapeStrings ? this.dataTypeEncoder.stripSlashes(decodedString) : decodedString;
        jsonLevel[prop] = decodedString;
      } else if (typeof jsonLevel[prop] === 'object') {
        this.decodeJSONStrings(jsonLevel[prop], unescapeStrings);
      }
    }
  }

  // Loading variables
  // ----------------------------------------------------------------------------------------------------------------------------------------

  /**
   * Takes a parsed input data type, looks for variable placeholder strings and replaces them with the actual variables
   *
   * IMPORTANT: To correctly find variables, their substrings, subfunction and subbrackets must be encoded (done in evaluate())
   *
   * @param input - The input variable to check
   * @param context - The current context object, if any
   * @param event - The current event object, if any
   * @param unescapeStrings - Whether to unescape strings or not
   * @param trackContextVariables - An optional object that will be filled out with all found context vars
   * @param allowContextFunctionCalls - Whether function calls in context vars are allowed
   */
  loadVariables(input: any, context: any = {}, event?: any, unescapeStrings: boolean = true, trackContextVariables: any = {}, allowContextFunctionCalls: boolean = true): any {
    const wrapper = {result: input};
    this.loadVariablesLoop(wrapper, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);
    return wrapper.result;
  }

  /**
   * Travels a JSON-like object to find all context vars and event objects and replaces their placeholders with the actual values
   *
   * @param arrayOrObject - The property of the JSON to analyze
   * @param context - The current context object, if any
   * @param event - The current event object, if any
   * @param unescapeStrings - Whether to unescape strings or not
   * @param trackContextVariables - Whether to unescape strings or not
   * @param allowContextFunctionCalls - Whether function calls in context vars are allowed
   */
  loadVariablesLoop(arrayOrObject: any, context: any = {}, event?: any, unescapeStrings: boolean = true, trackContextVariables: any = {}, allowContextFunctionCalls: boolean = true): void {
    for (const prop in arrayOrObject) {
      // Only interested in strings
      if (typeof arrayOrObject[prop] === 'string') {
        // If event placeholder
        if (arrayOrObject[prop] === '__EVENT__') {
          arrayOrObject[prop] = event;
        } else
        // If context var placeholder
        if (arrayOrObject[prop].match(new RegExp('^\\s*' + regexes.placeholderContextVariableRegex + '\\s*$', 'gm'))) {
          const contextVar = this.dataTypeEncoder.transformPlaceholderIntoContextVar(arrayOrObject[prop].trim());
          arrayOrObject[prop] = this.safelyLoadContextVariable(contextVar, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);
        }
      } else if (typeof arrayOrObject[prop] === 'object') {
        this.loadVariablesLoop(arrayOrObject[prop], context, event, unescapeStrings, trackContextVariables);
      }
    }
  }

  /**
   * A safe wrapper around the loadContextVariable function. Returns undefined if there is any error.
   *
   * @param contextVar - The context var
   * @param context - The context object
   * @param event - An event object, if available
   * @param unescapeStrings - Whether to unescape strings or not
   * @param trackContextVariables - An optional object that will be filled out with all found context vars
   * @param allowContextFunctionCalls - Whether function calls in context vars are allowed
   */
  safelyLoadContextVariable(contextVar: string, context: any = {}, event?: any, unescapeStrings: boolean = true, trackContextVariables: any = {}, allowContextFunctionCalls: boolean = true): any {
    try {
      const resolvedContextVariable = this.loadContextVariable(contextVar, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);
      trackContextVariables[this.decodeDataTypeString(contextVar)] = resolvedContextVariable;
      return resolvedContextVariable;
    } catch (e) {
      if (isDevMode()) {
        console.warn(e);
      }
      trackContextVariables[this.decodeDataTypeString(contextVar)] = undefined;
      return undefined;
    }
  }

  /**
   * Takes a context variable string and evaluates it to get the desired value
   *
   * IMPORTANT: To correctly parse variables, their substrings, subfunction and subbrackets must be encoded (done in evaluate())
   *
   * @param contextVar - The context var
   * @param context - The context object
   * @param event - An event object, if available
   * @param unescapeStrings - Whether to unescape strings or not
   * @param trackContextVariables - An optional object that will be filled out with all found context vars
   * @param allowContextFunctionCalls - Whether function calls in context vars are allowed
   */
  loadContextVariable(contextVar: string, context: any = {}, event?: any, unescapeStrings: boolean = true, trackContextVariables: any = {}, allowContextFunctionCalls: boolean = true): any {
    const shortContextVar = contextVar.substr(7);  // Cut off 'context' from the front

    // If context object is requested directly
    if (shortContextVar.trim() === '') {
      return context;
    }

    // Otherwise, create variable path array and fetch value, so the context object can be easily travelled.
    // Variable path example: 'restaurants["newOrleans"].reviews[5]' becomes ['restaurants', 'newOrleans', 'reviews', 5],
    const path = [];
    const pathMatches = matchAll(shortContextVar, new RegExp(regexes.variablePathPartRegex, 'gm'));
    for (const match of pathMatches) {

      // 1. If dot notation
      if (match[0].startsWith('.')) {
        path.push({
          type: 'property',
          value: match[0].substr(1)
        });
      }

      // 2. If bracket notation
      if (match[0].startsWith('[') && match[0].endsWith(']')) {
        let bracketValue = match[0].substr(1, match[0].length - 2);

        // Evaluate bracket parameter
        bracketValue = this.decodeDataTypeString(bracketValue);                                                                                             // Decode variable
        bracketValue = this.evaluate(bracketValue, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);    // Recursively repeat the process
        path.push({
          type: 'property',
          value: bracketValue
        });
      }

      // 3. If function call
      if (match[0].startsWith('(') && match[0].endsWith(')')) {
        // Check if function calls are allowed
        if (!allowContextFunctionCalls) {
          throw Error('Tried to call a function in a context variable. This has been disallowed in the current config.');
        }

        const funcParams = match[0].substr(1, match[0].length - 2);  // Strip outer brackets
        // Evaluate function parameters
        const paramsArray = [];
        if (funcParams !== '') {
          for (const param of funcParams.split(',')) {
            let p = this.decodeDataTypeString(param);                                                                                     // Decode variable
            p = this.evaluate(p, context, event, unescapeStrings, trackContextVariables, allowContextFunctionCalls);    // Recursively repeat the process
            paramsArray.push(p);
          }
        }
        // Add function to path
        path.push({
          type: 'function',
          value: paramsArray
        });
      }
    }

    try {
      return this.fetchContextVariable(context, JSON.parse(JSON.stringify(path)));
    } catch (e) {
      if (isDevMode()) {
        console.warn(e);
      }
      throw Error('The required context variable "' + this.decodeDataTypeString(contextVar) + '" could not be found in the context object. Returning undefined instead.');
    }
  }

  /**
   * Recursively travels an object with the help of a path array and returns the specified value,
   * or undefined if not found
   *
   * @param contextLevel - The object to travel
   * @param path - The property path array
   */
  fetchContextVariable(contextLevel: any, path: Array<any>): any {
    // Prevent accessing protected properties
    if (path[0].value ===  '__proto__') {
      throw Error('Accessing the __proto__ property through a context variable is not allowed.');
    }
    if (path[0].value ===  'prototype') {
      throw Error('Accessing the prototype property through a context variable is not allowed.');
    }
    if (path[0].value ===  'constructor') {
      throw Error('Accessing the constructor property through a context variable is not allowed.');
    }

    if (contextLevel === undefined) {
      throw Error('Context variable path could not be resolved. Trying to access ' + (path[0].type === 'property' ? 'property "' + path[0].value + '" of undefined.' : 'undefined function.'));
    }

    // Get property
    let result;
    if (path[0].type === 'property') {
      if (contextLevel.hasOwnProperty(path[0].value)) {
        result = contextLevel[path[0].value];
        // It makes a difference to JavaScript whether you call a function by 'obj.func()' or by 'let func = obj.func; func();'
        // In the latter case, 'this' will be undefined and not point to the parent. Since this recursive approach uses that latter version,
        // manually bind each function to the parent to restore the normal behavior.
        // Also: If the user has submitted a bound function himself, calling .bind here again does nothing, which is the desired behaviour.
        if (typeof result === 'function') {
          result = result.bind(contextLevel);
        }
      // Check '__proto__' as well as functions tend to live here instead of directly on the instance
      } else if (contextLevel.__proto__.hasOwnProperty(path[0].value))  {
        result = contextLevel.__proto__[path[0].value];
        if (typeof result === 'function') {
          result = result.bind(contextLevel);
        }
      } else {
        result = undefined;
      }
    } else if (path[0].type === 'function') {
      result = contextLevel(...path[0].value);
    }
    path.shift();

    // Recursively travel path
    if (path.length > 0) {
      result = this.fetchContextVariable(result, path);
    }
    return result;
  }

}