import {parseExpression, BabylonOptions} from 'babylon';
import * as b from 'babel-types';
import binaryOperation from './binaryOperation';

export {BabylonOptions};

export interface ExpressionToConstantOptions {
  constants?: any;
}

export interface Options extends ExpressionToConstantOptions {
  babylon?: BabylonOptions;
}
export function expressionToConstant(
  expression: b.Expression,
  options: ExpressionToConstantOptions = {},
): {constant: true; result: any} | {constant: false; result?: void} {
  let constant = true;
  function toConstant(expression: b.Expression): any {
    if (!constant) return;
    if (b.isArrayExpression(expression)) {
      const result = [];
      for (let i = 0; constant && i < expression.elements.length; i++) {
        const element = expression.elements[i];
        if (b.isSpreadElement(element)) {
          const spread = toConstant(element.argument);
          if (!(isSpreadable(spread) && constant)) {
            constant = false;
          } else {
            result.push(...spread);
          }
        } else {
          result.push(toConstant(element));
        }
      }
      return result;
    }
    if (b.isBinaryExpression(expression)) {
      const left = toConstant(expression.left);
      const right = toConstant(expression.right);
      return constant && binaryOperation(expression.operator, left, right);
    }
    if (b.isBooleanLiteral(expression)) {
      return expression.value;
    }
    if (b.isCallExpression(expression)) {
      const args = [];
      for (let i = 0; constant && i < expression.arguments.length; i++) {
        const arg = expression.arguments[i];
        if (b.isSpreadElement(arg)) {
          const spread = toConstant(arg.argument);
          if (!(isSpreadable(spread) && constant)) {
            constant = false;
          } else {
            args.push(...spread);
          }
        } else {
          args.push(toConstant(arg));
        }
      }
      if (!constant) return;
      if (b.isMemberExpression(expression.callee)) {
        const object = toConstant(expression.callee.object);
        if (!object || !constant) {
          constant = false;
          return;
        }
        const member = expression.callee.computed
          ? toConstant(expression.callee.property)
          : b.isIdentifier(expression.callee.property)
            ? expression.callee.property.name
            : undefined;
        if (member === undefined && !expression.callee.computed) {
          constant = false;
        }
        if (!constant) return;
        if (canCallMethod(object, '' + member)) {
          return object[member].apply(object, args);
        }
      } else {
        const callee = toConstant(expression.callee);
        if (!constant) return;
        return callee.apply(null, args);
      }
    }
    if (b.isConditionalExpression(expression)) {
      const test = toConstant(expression.test);
      return test
        ? toConstant(expression.consequent)
        : toConstant(expression.alternate);
    }
    if (b.isIdentifier(expression)) {
      if (
        options.constants &&
        {}.hasOwnProperty.call(options.constants, expression.name)
      ) {
        return options.constants[expression.name];
      }
    }
    if (b.isLogicalExpression(expression)) {
      const left = toConstant(expression.left);
      const right = toConstant(expression.right);
      if (constant && expression.operator === '&&') {
        return left && right;
      }
      if (constant && expression.operator === '||') {
        return left || right;
      }
    }
    if (b.isMemberExpression(expression)) {
      const object = toConstant(expression.object);
      if (!object || !constant) {
        constant = false;
        return;
      }
      const member = expression.computed
        ? toConstant(expression.property)
        : b.isIdentifier(expression.property)
          ? expression.property.name
          : undefined;
      if (member === undefined && !expression.computed) {
        constant = false;
      }
      if (!constant) return;
      if ({}.hasOwnProperty.call(object, '' + member) && member[0] !== '_') {
        return object[member];
      }
    }
    if (b.isNullLiteral(expression)) {
      return null;
    }
    if (b.isNumericLiteral(expression)) {
      return expression.value;
    }
    if (b.isObjectExpression(expression)) {
      const result: any = {};
      for (let i = 0; constant && i < expression.properties.length; i++) {
        const property = expression.properties[i];
        if (b.isObjectProperty(property)) {
          if (property.shorthand) {
            constant = false;
            return;
          }
          const key = property.computed
            ? toConstant(property.key)
            : b.isIdentifier(property.key)
              ? property.key.name
              : b.isStringLiteral(property.key)
                ? property.key.value
                : undefined;
          if (!key || key[0] === '_') {
            constant = false;
          }
          if (!constant) return;
          const value = toConstant(property.value);
          if (!constant) return;
          result[key] = value;
        } else if (b.isObjectMethod(property)) {
          constant = false;
        } else if (b.isSpreadProperty(property)) {
          const argument = toConstant(property.argument);
          if (!argument) constant = false;
          if (!constant) return;
          Object.assign(result, argument);
        }
      }
      return result;
    }
    if (b.isParenthesizedExpression(expression)) {
      return toConstant(expression.expression);
    }
    if (b.isRegExpLiteral(expression)) {
      return new RegExp(expression.pattern, expression.flags);
    }
    if (b.isSequenceExpression(expression)) {
      for (let i = 0; i < expression.expressions.length - 1 && constant; i++) {
        toConstant(expression.expressions[i]);
      }
      return toConstant(
        expression.expressions[expression.expressions.length - 1],
      );
    }
    if (b.isStringLiteral(expression)) {
      return expression.value;
    }
    // TODO: TaggedTemplateExpression
    if (b.isTemplateLiteral(expression)) {
      let result = '';
      for (let i = 0; i < expression.quasis.length; i++) {
        const quasi = expression.quasis[i];
        result += quasi.value.cooked;
        if (i < expression.expressions.length) {
          result += '' + toConstant(expression.expressions[i]);
        }
      }
      return result;
    }
    if (b.isUnaryExpression(expression)) {
      const argument = toConstant(expression.argument);
      if (!constant) {
        return;
      }
      switch (expression.operator) {
        case '-':
          return -argument;
        case '+':
          return +argument;
        case '!':
          return !argument;
        case '~':
          return ~argument;
        case 'typeof':
          return typeof argument;
        case 'void':
          return void argument;
      }
    }
    constant = false;
  }
  const result = toConstant(expression);
  return constant ? {constant: true, result} : {constant: false};
}
function isSpreadable(value: any): boolean {
  return (
    typeof value === 'string' ||
    Array.isArray(value) ||
    (typeof Set !== 'undefined' && value instanceof Set) ||
    (typeof Map !== 'undefined' && value instanceof Map)
  );
}
function shallowEqual(a: any, b: any) {
  if (a === b) return true;
  if (a && b && typeof a === 'object' && typeof b === 'object') {
    for (let key in a) {
      if (a[key] !== b[key]) {
        return false;
      }
    }
    for (let key in b) {
      if (a[key] !== b[key]) {
        return false;
      }
    }
    return true;
  }
  return false;
}
function canCallMethod(object: any, member: string): boolean {
  switch (typeof object) {
    case 'boolean':
      switch (member) {
        case 'toString':
          return true;
        default:
          return false;
      }
    case 'number':
      switch (member) {
        case 'toExponential':
        case 'toFixed':
        case 'toPrecision':
        case 'toString':
          return true;
        default:
          return false;
      }
    case 'string':
      switch (member) {
        case 'charAt':
        case 'charCodeAt':
        case 'codePointAt':
        case 'concat':
        case 'endsWith':
        case 'includes':
        case 'indexOf':
        case 'lastIndexOf':
        case 'match':
        case 'normalize':
        case 'padEnd':
        case 'padStart':
        case 'repeat':
        case 'replace':
        case 'search':
        case 'slice':
        case 'split':
        case 'startsWith':
        case 'substr':
        case 'substring':
        case 'toLowerCase':
        case 'toUpperCase':
        case 'trim':
          return true;
        default:
          return false;
      }
    default:
      if (object instanceof RegExp) {
        switch (member) {
          case 'test':
          case 'exec':
            return true;
          default:
            return false;
        }
      }
      return {}.hasOwnProperty.call(object, member) && member[0] !== '_';
  }
}

const EMPTY_OBJECT = {};
let lastSrc = '';
let lastConstants = EMPTY_OBJECT;
let lastOptions = EMPTY_OBJECT;
let lastResult: any = null;
let lastWasConstant = false;
export function isConstant(
  src: string,
  constants: any = EMPTY_OBJECT,
  options: BabylonOptions = EMPTY_OBJECT,
) {
  if (
    lastSrc === src &&
    shallowEqual(lastConstants, constants) &&
    shallowEqual(lastOptions, options)
  ) {
    return lastWasConstant;
  }
  lastSrc = src;
  lastConstants = constants;
  let ast: b.Expression | void;
  try {
    ast = parseExpression(src, options);
  } catch (ex) {
    return (lastWasConstant = false);
  }
  const {result, constant} = expressionToConstant(ast, {constants});
  lastResult = result;
  return (lastWasConstant = constant);
}
export function toConstant(
  src: string,
  constants: any = EMPTY_OBJECT,
  options: BabylonOptions = EMPTY_OBJECT,
) {
  if (!isConstant(src, constants, options)) {
    throw new Error(JSON.stringify(src) + ' is not constant.');
  }
  return lastResult;
}

export default isConstant;

module.exports = isConstant;
module.exports.default = isConstant;
module.exports.expressionToConstant = expressionToConstant;
module.exports.isConstant = isConstant;
module.exports.toConstant = toConstant;
