#!/usr/bin/env node

const babel = require('@babel/core');
const babelParser = require('@babel/parser');
const fs = require('fs');
const JestHasteMap = require('jest-haste-map');
const _ = require('lodash');
const path = require('path');
const mkdirp = require('mkdirp');

const MetroConfig = require('metro-config');
const metroDefaults = MetroConfig
  .getDefaultConfig
  .getDefaultValues(path.resolve(__dirname, '..'))
  .resolver;

const ROOTS = [
  path.resolve(__dirname, '..', 'Libraries'),
  path.resolve(__dirname, '..', 'jest'),
  path.resolve(__dirname, '..', 'integrationTests'),
  path.resolve(__dirname, '..', 'RNTester'),
];

const OVERRIDES = {
  'React': 'react',
};

const ignoreREs = [];

async function main() {
  // console.log('Cleaning...');
  // rimraf.sync(LibrariesDest);

  const haste = createHasteMap();
  console.log('Loading dependency graph...');
  const { moduleMap } = await haste.build();
  console.log('Loaded dependency graph.');
  // await transformRequires({
  //   source: ROOTS[1],
  //   dest: ROOTS[1],
  //   moduleMap: moduleMap.getRawModuleMap().map,
  // });
  for (let rootDir of ROOTS) {
    await transformRequires({
      source: rootDir,
      dest: rootDir,
      moduleMap: moduleMap.getRawModuleMap().map,
    });
  }
}

async function transformRequires({ source, dest, moduleMap }) {
  const sourceDir = fs.readdirSync(source);
  for (let filename of sourceDir) {
    const filePath = path.resolve(source, filename);
    if (_.some(ignoreREs.map(r => filePath.match(r)))) {
      continue;
    }
    const fileStats = fs.statSync(filePath);
    if (fileStats.isDirectory()) {
      await transformRequires({
        source: filePath,
        dest: path.join(dest, filename),
        moduleMap,
      });
    } else {
      await _transformRequiresInFile(
        filePath,
        path.join(dest, filename),
        moduleMap
      );
    }
  }
}

function _transformRequiresInFile(sourceFilePath, destFilePath, moduleMap) {
  const dirname = path.dirname(destFilePath);
  // Make the directory if it doesn't exist
  mkdirp.sync(dirname);

  // If not a JS file, just copy the file
  if (!sourceFilePath.endsWith('.js')) {
    // console.log(`Writing ${destFilePath}...`);
    // fs
    //   .createReadStream(sourceFilePath)
    //   .pipe(fs.createWriteStream(destFilePath));
    return;
  }

  // Get dependencies
  const code = fs.readFileSync(sourceFilePath, 'utf8');
  console.log(`Writing ${destFilePath}...`);
  const { dependencyOffsets, dependencies } = extractDependencies(code);

  const dependencyMap = dependencies.reduce((result, dep, i) => {
    if (!moduleMap.has(dep)) {
      return result;
    }

    let depPath;
    if (OVERRIDES[dep]) {
      depPath = OVERRIDES[dep];
    } else {
      const mod = moduleMap.get(dep);
      let modulePath;
      if (mod.g) {
        modulePath = mod.g[0];
      } else if (mod.ios) {
        modulePath = mod.ios[0];
      } else if (mod.android) {
        modulePath = mod.android[0];
      } else {
        return result;
      }

      depPath = path.relative(path.dirname(sourceFilePath), modulePath);
      if (!depPath.startsWith('.')) {
        depPath = `./${depPath}`;
      }
      depPath = depPath.replace(/(.*)\.[^.]+$/, '$1'); // remove extension
      depPath = depPath.replace(/(.*).(android|ios)/, '$1'); // remove platform ext
    }

    return Object.assign({}, result, {
      [dep]: {
        offset: dependencyOffsets[i],
        replacement: depPath,
      },
    });
  }, {});

  const newCode = dependencyOffsets
    .reduceRight(
      ([unhandled, handled], offset) => [
        unhandled.slice(0, offset),
        replaceDependency(unhandled.slice(offset) + handled, dependencyMap),
      ],
      [code, '']
    )
    .join('');

  fs.writeFileSync(destFilePath, newCode);
}

function createHasteMap() {
  return new JestHasteMap({
    extensions: metroDefaults.sourceExts.concat(metroDefaults.assetExts),
    hasteImplModulePath: path.resolve(__dirname, '../jest/hasteImpl'),
    maxWorkers: 1,
    ignorePattern: /\/__tests__\//,
    mocksPattern: '',
    platforms: metroDefaults.platforms,
    providesModuleNodeModules: [],
    resetCache: true,
    retainAllFiles: true,
    rootDir: path.resolve(__dirname, '..'),
    roots: ROOTS,
    useWatchman: true,
    watch: false,
  });
}

const reDepencencyString = /^(['"])([^'"']*)\1/;
function replaceDependency(stringWithDependencyIDAtStart, dependencyMap) {
  const match = reDepencencyString.exec(stringWithDependencyIDAtStart);
  const dependencyName = match && match[2];
  if (match != null && dependencyName in dependencyMap) {
    const { length } = match[0];
    const { replacement } = dependencyMap[dependencyName];
    return `'${replacement}'` + stringWithDependencyIDAtStart.slice(length);
  } else {
    return stringWithDependencyIDAtStart;
  }
}

/**
 * Extracts dependencies (module IDs imported with the `require` function) from
 * a string containing code. This walks the full AST for correctness (versus
 * using, for example, regular expressions, that would be faster but inexact.)
 *
 * The result of the dependency extraction is an de-duplicated array of
 * dependencies, and an array of offsets to the string literals with module IDs.
 * The index points to the opening quote.
 */
function extractDependencies(code) {
  const ast = babelParser.parse(code, {
    sourceType: 'module',
    plugins: [
      'classProperties',
      'jsx',
      'flow',
      'exportExtensions',
      'asyncGenerators',
      'objectRestSpread',
      'optionalChaining',
      'nullishCoalescingOperator',
    ],
  });
  const dependencies = new Set();
  const dependencyOffsets = [];
  const types = require('@babel/types');

  const transformedFunctions = [
    'require',
    'require.resolve',
    'jest.requireActual',
    // 'jest.requireMock',
    'System.import',
    'mockComponent',
  ];

  const isJest = node => {
    try {
      let callee;
      if (node.isCallExpression()) {
        callee = node.get('callee');
      } else if (node.isMemberExpression()) {
        callee = node;
      }
      return (
        callee.get('object').isIdentifier({ name: 'jest' }) ||
        (callee.isMemberExpression() && isJest(callee.get('object')))
      );
    } catch (e) {
      return false;
    }
  };

  const isValidJestFunc = node => {
    return (
      node.isIdentifier({ name: 'mock' }) ||
      node.isIdentifier({ name: 'unmock' }) ||
      node.isIdentifier({ name: 'doMock' }) ||
      node.isIdentifier({ name: 'dontMock' }) ||
      node.isIdentifier({ name: 'setMock' }) ||
      node.isIdentifier({ name: 'genMockFromModule' })
    );
  };

  const transformCall = nodePath => {
    if (isJest(nodePath)) {
      const calleeProperty = nodePath.get('callee.property');
      if (isValidJestFunc(calleeProperty)) {
        const arg = nodePath.get('arguments.0');
        if (!arg || arg.type !== 'StringLiteral') {
          return;
        }
        dependencyOffsets.push(parseInt(arg.node.start, 10));
        dependencies.add(arg.node.value);
      }
    } else {
      const calleePath = nodePath.get('callee');
      const isNormalCall = transformedFunctions.some(pattern =>
        _matchesPattern(types, calleePath, pattern)
      );
      if (isNormalCall) {
        const arg = nodePath.get('arguments.0');
        if (!arg || arg.type !== 'StringLiteral') {
          return;
        }
        dependencyOffsets.push(parseInt(arg.node.start, 10));
        dependencies.add(arg.node.value);
      }
    }
  };

  babel.traverse(ast, {
    ImportDeclaration: (nodePath) => {
      const sourceNode = nodePath.get('source').node;
      dependencyOffsets.push(parseInt(sourceNode.start, 10));
      dependencies.add(sourceNode.value);
    },
    CallExpression: transformCall,
  });

  return {
    dependencyOffsets: [...dependencyOffsets].sort((a, b) => a - b),
    dependencies: Array.from(dependencies),
  };
}

function _matchesPattern(types, calleePath, pattern) {
  const { node } = calleePath;

  if (types.isMemberExpression(node)) {
    return calleePath.matchesPattern(pattern);
  }

  if (!types.isIdentifier(node) || pattern.includes('.')) {
    return false;
  }

  const name = pattern.split('.')[0];

  return node.name === name;
}

main().catch(e => {
  console.trace(e.message);
  throw e;
});