import * as babelCore from '@babel/core';
import { Scope } from '@babel/traverse';
// @ts-ignore
import splitExportDeclaration from '@babel/helper-split-export-declaration';
import path from 'path';
import babelPluginMacros, { createMacro } from 'babel-plugin-macros';
import { StateMachine } from 'xstate';
import { rollup } from 'rollup';
import babelPlugin from '@rollup/plugin-babel';
import nodeResolvePlugin from '@rollup/plugin-node-resolve';
import Module from 'module';

const generateRandomId = (): string =>
  Math.random()
    .toString(36)
    .substring(2);

const generateUniqueId = (map: Record<string, any>): string => {
  const id = generateRandomId();
  return Object.prototype.hasOwnProperty.call(map, id)
    ? generateUniqueId(map)
    : id;
};

const compiledOutputs: Record<string, string> = Object.create(null);
(Module as any)._extensions['.xstate.js'] = (module: any, filename: string) => {
  const [_match, id] = filename.match(/-(\w+)\.xstate\.js$/)!;
  module._compile(compiledOutputs[id], filename);
};

type UsedImport = {
  localName: string;
  importedName: string;
};

type ReferencePathsByImportName = Record<
  string,
  Array<babelCore.NodePath<babelCore.types.Node>>
>;

const cwd = process.cwd();
const extensions = ['.tsx', '.ts', '.jsx', '.js'];

const getImports = (
  { types: t }: typeof babelCore,
  path: babelCore.NodePath<babelCore.types.ImportDeclaration>,
): UsedImport[] => {
  return path.node.specifiers.map((specifier) => {
    if (t.isImportNamespaceSpecifier(specifier)) {
      throw new Error(
        'Using a namespace import for `@xstate/import` is not supported.',
      );
    }
    return {
      localName: specifier.local.name,
      importedName:
        specifier.type === 'ImportDefaultSpecifier'
          ? 'default'
          : specifier.local.name,
    };
  });
};

const getReferencePathsByImportName = (
  scope: Scope,
  imports: UsedImport[],
): ReferencePathsByImportName | undefined => {
  let shouldExit = false;
  let hasReferences = false;
  const referencePathsByImportName = imports.reduce(
    (byName, { importedName, localName }) => {
      let binding = scope.getBinding(localName);
      if (!binding) {
        shouldExit = true;
        return byName;
      }
      byName[importedName] = binding.referencePaths;
      hasReferences = hasReferences || Boolean(byName[importedName].length);
      return byName;
    },
    {} as ReferencePathsByImportName,
  );

  if (!hasReferences || shouldExit) {
    return;
  }

  return referencePathsByImportName;
};

const getMachineId = (
  importName: string,
  { types: t }: typeof babelCore,
  callExpression: babelCore.types.CallExpression,
) => {
  const { typeParameters } = callExpression;

  if (
    !typeParameters ||
    !typeParameters.params[2] ||
    !t.isTSLiteralType(typeParameters.params[2]) ||
    !t.isStringLiteral(typeParameters.params[2].literal)
  ) {
    console.log('You must pass three type arguments to your machine.');
    console.log();
    console.log('For instance:');
    console.log(
      `const machine = ${importName}<Context, Event, 'aUniqueIdForYourMachine'>({})`,
    );
    console.log();
    throw new Error('You must pass three type arguments to your machine.');
  }
  return typeParameters.params[2].literal.value;
};

const insertExtractingExport = (
  { types: t }: typeof babelCore,
  statementPath: babelCore.NodePath<babelCore.types.Statement>,
  {
    importName,
    index,
    machineId,
    machineIdentifier,
  }: {
    importName: string;
    index: number;
    machineId: string;
    machineIdentifier: string;
  },
) => {
  statementPath.insertAfter(
    t.exportNamedDeclaration(
      t.variableDeclaration('var', [
        t.variableDeclarator(
          t.identifier(`__xstate_${importName}_${index}`),
          t.objectExpression([
            t.objectProperty(t.identifier('id'), t.stringLiteral(machineId)),
            t.objectProperty(
              t.identifier('machine'),
              t.identifier(machineIdentifier),
            ),
          ]),
        ),
      ]),
    ),
  );
};

const handleMachineFactoryCalls = (
  importName: string,
  { references, babel }: babelPluginMacros.MacroParams,
) => {
  if (!references[importName]) {
    return;
  }

  const { types: t } = babel;

  references[importName].forEach((referencePath, index) => {
    const callExpressionPath = referencePath.parentPath;

    if (!t.isCallExpression(callExpressionPath.node)) {
      throw new Error(`\`${importName}\` can only be called.`);
    }
    const machineId = getMachineId(importName, babel, callExpressionPath.node);

    const callExpressionParentPath = callExpressionPath.parentPath;
    const callExpressionParentNode = callExpressionParentPath.node;

    switch (callExpressionParentNode.type) {
      case 'VariableDeclarator': {
        if (!t.isIdentifier(callExpressionParentNode.id)) {
          throw new Error(
            `Result of the \`${importName}\` call can only appear in the variable declaration.`,
          );
        }
        const statementPath = callExpressionParentPath.getStatementParent();
        if (!statementPath.parentPath.isProgram()) {
          throw new Error(
            `\`${importName}\` calls can only appear in top-level statements.`,
          );
        }

        insertExtractingExport(babel, statementPath, {
          importName,
          index,
          machineId,
          machineIdentifier: callExpressionParentNode.id.name,
        });

        break;
      }
      case 'ExportDefaultDeclaration': {
        splitExportDeclaration(callExpressionParentPath);

        insertExtractingExport(
          babel,
          callExpressionParentPath.getStatementParent(),
          {
            importName,
            index,
            machineId,
            machineIdentifier: ((callExpressionParentPath as babelCore.NodePath<
              babelCore.types.VariableDeclaration
            >).node.declarations[0].id as babelCore.types.Identifier).name,
          },
        );
        break;
      }
      default: {
        throw new Error(
          `\`${importName}\` calls can only appear in the variable declaration or as a default export.`,
        );
      }
    }
  });
};

const macro = createMacro((params) => {
  handleMachineFactoryCalls('createMachine', params);
  handleMachineFactoryCalls('Machine', params);
});

type ExtractedMachine = {
  id: string;
  machine: StateMachine<any, any, any>;
};

const getCreatedExports = (
  importName: string,
  exportsObj: Record<string, any>,
): ExtractedMachine[] => {
  const extracted: ExtractedMachine[] = [];
  let counter = 0;
  while (true) {
    const currentCandidate = exportsObj[`__xstate_${importName}_${counter++}`];
    if (!currentCandidate) {
      return extracted;
    }
    extracted.push(currentCandidate);
  }
};

export const extractMachines = async (
  filePath: string,
): Promise<ExtractedMachine[]> => {
  const resolvedFilePath = path.resolve(cwd, filePath);

  const build = await rollup({
    input: resolvedFilePath,
    external: (id) => !/^(\.|\/|\w:)/.test(id),
    plugins: [
      nodeResolvePlugin({
        extensions,
      }),
      babelPlugin({
        babelHelpers: 'bundled',
        extensions,
        plugins: [
          '@babel/plugin-transform-typescript',
          '@babel/plugin-proposal-optional-chaining',
          '@babel/plugin-proposal-nullish-coalescing-operator',
          (babel: typeof babelCore) => {
            return {
              name: 'xstate-codegen-machines-extractor',
              visitor: {
                ImportDeclaration(
                  path: babelCore.NodePath<babelCore.types.ImportDeclaration>,
                  state: babelCore.PluginPass,
                ) {
                  if (
                    state.filename !== resolvedFilePath ||
                    path.node.source.value !== '@xstate/compiled'
                  ) {
                    return;
                  }

                  const imports = getImports(babel, path);
                  const referencePathsByImportName = getReferencePathsByImportName(
                    path.scope,
                    imports,
                  );

                  if (!referencePathsByImportName) {
                    return;
                  }

                  /**
                   * Other plugins that run before babel-plugin-macros might use path.replace, where a path is
                   * put into its own replacement. Apparently babel does not update the scope after such
                   * an operation. As a remedy, the whole scope is traversed again with an empty "Identifier"
                   * visitor - this makes the problem go away.
                   *
                   * See: https://github.com/kentcdodds/import-all.macro/issues/7
                   */
                  state.file.scope.path.traverse({
                    Identifier() {},
                  });

                  macro({
                    path,
                    references: referencePathsByImportName,
                    state,
                    babel,
                    // hack to make this call accepted by babel-plugin-macros
                    isBabelMacrosCall: true,
                  });
                },
              },
            };
          },
        ],
      }),
    ],
  });
  const output = await build.generate({
    format: 'cjs',
    exports: 'named',
  });
  const chunk = output.output[0];
  const { code } = chunk;

  // dance with those unique ids is not really needed, at least right now
  // loading CJS modules is synchronous
  // once we start to support loading ESM this won't hold true anymore
  let uniqueId = generateUniqueId(compiledOutputs);

  try {
    compiledOutputs[uniqueId] = code;
    const fakeFileName = path.join(
      path.dirname(resolvedFilePath),
      `${path
        .basename(resolvedFilePath)
        .replace(/\./g, '-')}-${uniqueId}.xstate.js`,
    );
    const module = new Module(fakeFileName);
    (module as any).load(fakeFileName);

    return [
      ...getCreatedExports('createMachine', module.exports),
      ...getCreatedExports('Machine', module.exports),
    ];
  } finally {
    delete compiledOutputs[uniqueId];
  }
};