import { astRoot } from '../codegen';
import { parse } from '../parser';

export function makeHelpers({ types: t, template }) {
  const regexPatternsRe = /^[()\[\]|.+?*]|[^\\][()\[\]|.+?*$^]|\\[wdsWDS]/;
  const importSourceRe = /reghex$|^reghex\/macro/;
  const importName = 'reghex';

  let _hasUpdatedImport = false;
  let _matchId = t.identifier('match');
  let _patternId = t.identifier('__pattern');

  const _hoistedExpressions = new Map();

  return {
    /** Adds the reghex import declaration to the Program scope */
    updateImport(path) {
      if (_hasUpdatedImport) return;
      if (!importSourceRe.test(path.node.source.value)) return;
      _hasUpdatedImport = true;

      if (path.node.source.value !== importName) {
        path.node.source = t.stringLiteral(importName);
      }

      _patternId = path.scope.generateUidIdentifier('_pattern');
      path.node.specifiers.push(
        t.importSpecifier(_patternId, t.identifier('__pattern'))
      );

      const tagImport = path.node.specifiers.find((node) => {
        return t.isImportSpecifier(node) && node.imported.name === 'match';
      });

      if (!tagImport) {
        path.node.specifiers.push(
          t.importSpecifier(
            (_matchId = path.scope.generateUidIdentifier('match')),
            t.identifier('match')
          )
        );
      } else {
        _matchId = tagImport.imported;
      }
    },

    /** Determines whether the given tagged template expression is a reghex match */
    isMatch(path) {
      if (
        t.isTaggedTemplateExpression(path.node) &&
        t.isCallExpression(path.node.tag) &&
        t.isIdentifier(path.node.tag.callee) &&
        path.scope.hasBinding(path.node.tag.callee.name)
      ) {
        if (t.isVariableDeclarator(path.parentPath))
          path.parentPath._isMatch = true;
        return true;
      }

      return (
        t.isVariableDeclarator(path.parentPath) && path.parentPath._isMatch
      );
    },

    /** Given a reghex match, returns the path to reghex's match import declaration */
    getMatchImport(path) {
      t.assertTaggedTemplateExpression(path.node);
      const binding = path.scope.getBinding(path.node.tag.callee.name);

      if (
        binding.kind !== 'module' ||
        !t.isImportDeclaration(binding.path.parent) ||
        !importSourceRe.test(binding.path.parent.source.value) ||
        !t.isImportSpecifier(binding.path.node)
      ) {
        return null;
      }

      return binding.path.parentPath;
    },

    /** Given a match, returns an evaluated name or a best guess */
    getMatchName(path) {
      t.assertTaggedTemplateExpression(path.node);
      const nameArgumentPath = path.get('tag.arguments.0');
      if (nameArgumentPath) {
        const { confident, value } = nameArgumentPath.evaluate();
        if (!confident && t.isIdentifier(nameArgumentPath.node)) {
          return nameArgumentPath.node.name;
        } else if (confident && typeof value === 'string') {
          return value;
        }
      }

      return path.scope.generateUidIdentifierBasedOnNode(path.node);
    },

    /** Given a match, hoists its expressions in front of the match's statement */
    _prepareExpressions(path) {
      t.assertTaggedTemplateExpression(path.node);

      const variableDeclarators = [];
      const matchName = this.getMatchName(path);

      const hoistedExpressions = path.node.quasi.expressions.map(
        (expression, i) => {
          if (
            t.isArrowFunctionExpression(expression) &&
            t.isIdentifier(expression.body)
          ) {
            expression = expression.body;
          } else if (
            (t.isFunctionExpression(expression) ||
              t.isArrowFunctionExpression(expression)) &&
            t.isBlockStatement(expression.body) &&
            expression.body.body.length === 1 &&
            t.isReturnStatement(expression.body.body[0]) &&
            t.isIdentifier(expression.body.body[0].argument)
          ) {
            expression = expression.body.body[0].argument;
          }

          const isBindingExpression =
            t.isIdentifier(expression) &&
            path.scope.hasBinding(expression.name);
          if (isBindingExpression) {
            const binding = path.scope.getBinding(expression.name);
            if (t.isVariableDeclarator(binding.path.node)) {
              const matchPath = binding.path.get('init');
              if (this.isMatch(matchPath)) {
                return expression;
              } else if (_hoistedExpressions.has(expression.name)) {
                return t.identifier(_hoistedExpressions.get(expression.name));
              }
            }
          }

          const id = path.scope.generateUidIdentifier(
            isBindingExpression
              ? `${expression.name}_expression`
              : `${matchName}_expression`
          );

          variableDeclarators.push(
            t.variableDeclarator(
              id,
              t.callExpression(t.identifier(_patternId.name), [expression])
            )
          );

          if (t.isIdentifier(expression)) {
            _hoistedExpressions.set(expression.name, id.name);
          }

          return id;
        }
      );

      if (variableDeclarators.length) {
        path
          .getStatementParent()
          .insertBefore(t.variableDeclaration('var', variableDeclarators));
      }

      return hoistedExpressions.map((id) => {
        const binding = path.scope.getBinding(id.name);
        if (binding && t.isVariableDeclarator(binding.path.node)) {
          const matchPath = binding.path.get('init');
          if (this.isMatch(matchPath)) {
            return { fn: true, id: id.name };
          }
        }

        const input = t.isStringLiteral(id)
          ? JSON.stringify(id.value)
          : id.name;
        return { fn: false, id: input };
      });
    },

    _prepareTransform(path) {
      const transformNode = path.node.tag.arguments[1];

      if (!transformNode) return null;
      if (t.isIdentifier(transformNode)) return transformNode.name;

      const matchName = this.getMatchName(path);
      const id = path.scope.generateUidIdentifier(`${matchName}_transform`);
      const declarator = t.variableDeclarator(id, transformNode);

      path
        .getStatementParent()
        .insertBefore(t.variableDeclaration('var', [declarator]));

      return id.name;
    },

    minifyMatch(path) {
      const quasis = path.node.quasi.quasis.map((x) =>
        t.stringLiteral(x.value.cooked.replace(/\s*/g, ''))
      );
      const expressions = path.node.quasi.expressions;
      const transform = this._prepareTransform(path);

      path.replaceWith(
        t.callExpression(path.node.tag, [
          t.arrayExpression(quasis),
          ...expressions,
        ])
      );
    },

    transformMatch(path) {
      let name = path.node.tag.arguments[0];
      if (!name) {
        name = t.nullLiteral();
      }

      const quasis = path.node.quasi.quasis.map((x) => x.value.cooked);

      const expressions = this._prepareExpressions(path);
      const transform = this._prepareTransform(path);

      let ast;
      try {
        ast = parse(quasis, expressions);
      } catch (error) {
        if (error.name !== 'SyntaxError') throw error;
        throw path.get('quasi').buildCodeFrameError(error.message);
      }

      const code = astRoot(ast, '%%name%%', transform && '%%transform%%');

      path.replaceWith(
        template.expression(code)(transform ? { name, transform } : { name })
      );
    },
  };
}
