// Fork of https://github.com/loganfsmyth/babel-plugin-transform-decorators-legacy

import template from "babel-template";
import syntaxDecorators from "babel-plugin-syntax-decorators";

const buildClassDecorator = template(`
  DECORATOR(CLASS_REF = INNER) || CLASS_REF;
`);

const buildClassPrototype = template(`
  CLASS_REF.prototype;
`);

const buildGetDescriptor = template(`
    Object.getOwnPropertyDescriptor(TARGET, PROPERTY);
`);

const buildGetObjectInitializer = template(`
    (TEMP = Object.getOwnPropertyDescriptor(TARGET, PROPERTY), (TEMP = TEMP ? TEMP.value : undefined), {
        enumerable: true,
        configurable: true,
        writable: true,
        initializer: function(){
            return TEMP;
        }
    })
`);

const buildInitializerWarningHelper = template(`
    function NAME(descriptor, context){
        throw new Error(
          'Decorating class property failed. Please ensure that ' +
          'transform-class-properties is enabled.'
        );
    }
`);

const buildInitializerDefineProperty = template(`
    function NAME(target, property, descriptor, context){
        if (!descriptor) return;

        Object.defineProperty(target, property, {
            enumerable: descriptor.enumerable,
            configurable: descriptor.configurable,
            writable: descriptor.writable,
            value: descriptor.initializer ? descriptor.initializer.call(context) : void 0,
        });
    }
`);

const buildApplyDecoratedDescriptor = template(`
    function NAME(target, property, decorators, descriptor, context){
        var desc = {};
        Object['ke' + 'ys'](descriptor).forEach(function(key){
            desc[key] = descriptor[key];
        });
        desc.enumerable = !!desc.enumerable;
        desc.configurable = !!desc.configurable;
        if ('value' in desc || desc.initializer){
            desc.writable = true;
        }

        desc = decorators.slice().reverse().reduce(function(desc, decorator){
            return decorator(target, property, desc) || desc;
        }, desc);

        if (context && desc.initializer !== void 0){
            desc.value = desc.initializer ? desc.initializer.call(context) : void 0;
            desc.initializer = undefined;
        }

        if (desc.initializer === void 0){
            // This is a hack to avoid this being processed by 'transform-runtime'.
            // See issue #9.
            Object['define' + 'Property'](target, property, desc);
            desc = null;
        }

        return desc;
    }
`);

export default function({ types: t }) {
  /**
   * Add a helper to take an initial descriptor, apply some decorators to it, and optionally
   * define the property.
   */
  function ensureApplyDecoratedDescriptorHelper(path, state) {
    if (!state.applyDecoratedDescriptor) {
      state.applyDecoratedDescriptor = path.scope.generateUidIdentifier(
        "applyDecoratedDescriptor",
      );
      const helper = buildApplyDecoratedDescriptor({
        NAME: state.applyDecoratedDescriptor,
      });
      path.scope.getProgramParent().path.unshiftContainer("body", helper);
    }

    return state.applyDecoratedDescriptor;
  }

  /**
   * Add a helper to call as a replacement for class property definition.
   */
  function ensureInitializerDefineProp(path, state) {
    if (!state.initializerDefineProp) {
      state.initializerDefineProp = path.scope.generateUidIdentifier(
        "initDefineProp",
      );
      const helper = buildInitializerDefineProperty({
        NAME: state.initializerDefineProp,
      });
      path.scope.getProgramParent().path.unshiftContainer("body", helper);
    }

    return state.initializerDefineProp;
  }

  /**
   * Add a helper that will throw a useful error if the transform fails to detect the class
   * property assignment, so users know something failed.
   */
  function ensureInitializerWarning(path, state) {
    if (!state.initializerWarningHelper) {
      state.initializerWarningHelper = path.scope.generateUidIdentifier(
        "initializerWarningHelper",
      );
      const helper = buildInitializerWarningHelper({
        NAME: state.initializerWarningHelper,
      });
      path.scope.getProgramParent().path.unshiftContainer("body", helper);
    }

    return state.initializerWarningHelper;
  }

  /**
   * If the decorator expressions are non-identifiers, hoist them to before the class so we can be sure
   * that they are evaluated in order.
   */
  function applyEnsureOrdering(path) {
    // TODO: This should probably also hoist computed properties.
    const decorators = (path.isClass()
      ? [path].concat(path.get("body.body"))
      : path.get("properties")).reduce(
      (acc, prop) => acc.concat(prop.node.decorators || []),
      [],
    );

    const identDecorators = decorators.filter(
      decorator => !t.isIdentifier(decorator.expression),
    );
    if (identDecorators.length === 0) return;

    return t.sequenceExpression(
      identDecorators
        .map(decorator => {
          const expression = decorator.expression;
          const id = (decorator.expression = path.scope.generateDeclaredUidIdentifier(
            "dec",
          ));
          return t.assignmentExpression("=", id, expression);
        })
        .concat([path.node]),
    );
  }

  /**
   * Given a class expression with class-level decorators, create a new expression
   * with the proper decorated behavior.
   */
  function applyClassDecorators(classPath) {
    const decorators = classPath.node.decorators || [];
    classPath.node.decorators = null;

    if (decorators.length === 0) return;

    const name = classPath.scope.generateDeclaredUidIdentifier("class");

    return decorators
      .map(dec => dec.expression)
      .reverse()
      .reduce(function(acc, decorator) {
        return buildClassDecorator({
          CLASS_REF: name,
          DECORATOR: decorator,
          INNER: acc,
        }).expression;
      }, classPath.node);
  }

  /**
   * Given a class expression with method-level decorators, create a new expression
   * with the proper decorated behavior.
   */
  function applyMethodDecorators(path, state) {
    const hasMethodDecorators = path.node.body.body.some(function(node) {
      return (node.decorators || []).length > 0;
    });

    if (!hasMethodDecorators) return;

    return applyTargetDecorators(path, state, path.node.body.body);
  }

  /**
   * Given an object expression with property decorators, create a new expression
   * with the proper decorated behavior.
   */
  function applyObjectDecorators(path, state) {
    const hasMethodDecorators = path.node.properties.some(function(node) {
      return (node.decorators || []).length > 0;
    });

    if (!hasMethodDecorators) return;

    return applyTargetDecorators(path, state, path.node.properties);
  }

  /**
   * A helper to pull out property decorators into a sequence expression.
   */
  function applyTargetDecorators(path, state, decoratedProps) {
    const name = path.scope.generateDeclaredUidIdentifier(
      path.isClass() ? "class" : "obj",
    );

    const exprs = decoratedProps.reduce(function(acc, node) {
      const decorators = node.decorators || [];
      node.decorators = null;

      if (decorators.length === 0) return acc;

      if (node.computed) {
        throw path.buildCodeFrameError(
          "Computed method/property decorators are not yet supported.",
        );
      }

      const property = t.isLiteral(node.key)
        ? node.key
        : t.stringLiteral(node.key.name);

      const target =
        path.isClass() && !node.static
          ? buildClassPrototype({
              CLASS_REF: name,
            }).expression
          : name;

      if (t.isClassProperty(node, { static: false })) {
        const descriptor = path.scope.generateDeclaredUidIdentifier(
          "descriptor",
        );

        const initializer = node.value
          ? t.functionExpression(
              null,
              [],
              t.blockStatement([t.returnStatement(node.value)]),
            )
          : t.nullLiteral();
        node.value = t.callExpression(ensureInitializerWarning(path, state), [
          descriptor,
          t.thisExpression(),
        ]);

        acc = acc.concat([
          t.assignmentExpression(
            "=",
            descriptor,
            t.callExpression(
              ensureApplyDecoratedDescriptorHelper(path, state),
              [
                target,
                property,
                t.arrayExpression(decorators.map(dec => dec.expression)),
                t.objectExpression([
                  t.objectProperty(
                    t.identifier("enumerable"),
                    t.booleanLiteral(true),
                  ),
                  t.objectProperty(t.identifier("initializer"), initializer),
                ]),
              ],
            ),
          ),
        ]);
      } else {
        acc = acc.concat(
          t.callExpression(ensureApplyDecoratedDescriptorHelper(path, state), [
            target,
            property,
            t.arrayExpression(decorators.map(dec => dec.expression)),
            t.isObjectProperty(node) ||
            t.isClassProperty(node, { static: true })
              ? buildGetObjectInitializer({
                  TEMP: path.scope.generateDeclaredUidIdentifier("init"),
                  TARGET: target,
                  PROPERTY: property,
                }).expression
              : buildGetDescriptor({
                  TARGET: target,
                  PROPERTY: property,
                }).expression,
            target,
          ]),
        );
      }

      return acc;
    }, []);

    return t.sequenceExpression([
      t.assignmentExpression("=", name, path.node),
      t.sequenceExpression(exprs),
      name,
    ]);
  }

  return {
    inherits: syntaxDecorators,

    visitor: {
      ExportDefaultDeclaration(path) {
        if (!path.get("declaration").isClassDeclaration()) return;

        const { node } = path;
        const ref =
          node.declaration.id || path.scope.generateUidIdentifier("default");
        node.declaration.id = ref;

        // Split the class declaration and the export into two separate statements.
        path.replaceWith(node.declaration);
        path.insertAfter(
          t.exportNamedDeclaration(null, [
            t.exportSpecifier(ref, t.identifier("default")),
          ]),
        );
      },
      ClassDeclaration(path) {
        const { node } = path;

        const ref = node.id || path.scope.generateUidIdentifier("class");

        path.replaceWith(
          t.variableDeclaration("let", [
            t.variableDeclarator(ref, t.toExpression(node)),
          ]),
        );
      },
      ClassExpression(path, state) {
        // Create a replacement for the class node if there is one. We do one pass to replace classes with
        // class decorators, and a second pass to process method decorators.
        const decoratedClass =
          applyEnsureOrdering(path) ||
          applyClassDecorators(path, state) ||
          applyMethodDecorators(path, state);

        if (decoratedClass) path.replaceWith(decoratedClass);
      },
      ObjectExpression(path, state) {
        const decoratedObject =
          applyEnsureOrdering(path) || applyObjectDecorators(path, state);

        if (decoratedObject) path.replaceWith(decoratedObject);
      },

      AssignmentExpression(path, state) {
        if (!state.initializerWarningHelper) return;

        if (!path.get("left").isMemberExpression()) return;
        if (!path.get("left.property").isIdentifier()) return;
        if (!path.get("right").isCallExpression()) return;
        if (
          !path
            .get("right.callee")
            .isIdentifier({ name: state.initializerWarningHelper.name })
        ) {
          return;
        }

        path.replaceWith(
          t.callExpression(ensureInitializerDefineProp(path, state), [
            path.get("left.object").node,
            t.stringLiteral(path.get("left.property").node.name),
            path.get("right.arguments")[0].node,
            path.get("right.arguments")[1].node,
          ]),
        );
      },
    },
  };
}
