
/**
 * Copyright (c) Facebook, Inc. and its affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 */

'use strict';

const child_process = require('child_process');
const pc = require('picocolors');
const fs = require('graceful-fs');
const path = require('path');
const http = require('http');
const https = require('https');
const ignores = require('./ignoreFiles');

const tmp = require('tmp');
tmp.setGracefulCleanup();

const availableCpus = Math.max(require('os').cpus().length - 1, 1);
const CHUNK_SIZE = 50;

function lineBreak(str) {
  return /\n$/.test(str) ? str : str + '\n';
}

const bufferedWrite = (function() {
  const buffer = [];
  let buffering = false;

  process.stdout.on('drain', () => {
    if (!buffering) return;
    while (buffer.length > 0 && process.stdout.write(buffer.shift()) !== false);
    if (buffer.length === 0) {
      buffering = false;
    }
  });
  return function write(msg) {
    if (buffering) {
      buffer.push(msg);
    }
    if (process.stdout.write(msg) === false) {
      buffering = true;
    }
  };
}());

const log = {
  ok(msg, verbose) {
    verbose >= 2 && bufferedWrite(pc.bgGreen(pc.white(' OKK ')) + msg);
  },
  nochange(msg, verbose) {
    verbose >= 1 && bufferedWrite(pc.bgYellow(pc.white(' NOC ')) + msg);
  },
  skip(msg, verbose) {
    verbose >= 1 && bufferedWrite(pc.bgYellow(pc.white(' SKIP ')) + msg);
  },
  error(msg, verbose) {
    verbose >= 0 && bufferedWrite(pc.bgRed(pc.white(' ERR ')) + msg);
  },
};

function report({file, msg}) {
  bufferedWrite(lineBreak(`${pc.bgBlue(pc.white(' REP '))}${file} ${msg}`));
}

function concatAll(arrays) {
  const result = [];
  for (const array of arrays) {
    for (const element of array) {
      result.push(element);
    }
  }
  return result;
}

function showFileStats(fileStats) {
  process.stdout.write(
    'Results: \n'+
    pc.red(fileStats.error + ' errors\n')+
    pc.yellow(fileStats.nochange + ' unmodified\n')+
    pc.yellow(fileStats.skip + ' skipped\n')+
    pc.green(fileStats.ok + ' ok\n')
  );
}

function showStats(stats) {
  const names = Object.keys(stats).sort();
  if (names.length) {
    process.stdout.write(pc.blue('Stats: \n'));
  }
  names.forEach(name => process.stdout.write(name + ': ' + stats[name] + '\n'));
}

function dirFiles (dir, callback, acc) {
  // acc stores files found so far and counts remaining paths to be processed
  acc = acc || { files: [], remaining: 1 };

  function done() {
    // decrement count and return if there are no more paths left to process
    if (!--acc.remaining) {
      callback(acc.files);
    }
  }

  fs.readdir(dir, (err, files) => {
    // if dir does not exist or is not a directory, bail
    // (this should not happen as long as calls do the necessary checks)
    if (err) throw err;

    acc.remaining += files.length;
    files.forEach(file => {
      let name = path.join(dir, file);
      fs.stat(name, (err, stats) => {
        if (err) {
          // probably a symlink issue
          process.stdout.write(
            'Skipping path "' + name + '" which does not exist.\n'
          );
          done();
        } else if (ignores.shouldIgnore(name)) {
          // ignore the path
          done();
        } else if (stats.isDirectory()) {
          dirFiles(name + '/', callback, acc);
        } else {
          acc.files.push(name);
          done();
        }
      });
    });
    done();
  });
}

function getAllFiles(paths, filter) {
  return Promise.all(
    paths.map(file => new Promise(resolve => {
      fs.lstat(file, (err, stat) => {
        if (err) {
          process.stderr.write('Skipping path ' + file + ' which does not exist. \n');
          resolve([]);
          return;
        }

        if (stat.isDirectory()) {
          dirFiles(
            file,
            list => resolve(list.filter(filter))
          );
        } else if (!filter(file) || ignores.shouldIgnore(file)) {
          // ignoring the file
          resolve([]);
        } else {
          resolve([file]);
        }
      })
    }))
  ).then(concatAll);
}

function run(transformFile, paths, options) {
  const cpus = options.cpus ? Math.min(availableCpus, options.cpus) : availableCpus;
  const extensions =
    options.extensions && options.extensions.split(',').map(ext => '.' + ext);
  const fileCounters = {error: 0, ok: 0, nochange: 0, skip: 0};
  const statsCounter = {};
  const startTime = process.hrtime();

  ignores.add(options.ignoreSet);
  ignores.add(options.ignorePattern);
  ignores.addFromFile(options.ignoreConfig);

  if (options.gitignore) {
    let currDirectory = process.cwd();
    let gitIgnorePath = path.join(currDirectory, '.gitignore');
    ignores.addFromFile(gitIgnorePath);
  }

  if (/^http/.test(transformFile)) {
    return new Promise((resolve, reject) => {
      // call the correct `http` or `https` implementation
      (transformFile.indexOf('https') !== 0 ?  http : https).get(transformFile, (res) => {
        let contents = '';
        res
          .on('data', (d) => {
            contents += d.toString();
          })
          .on('end', () => {
            const ext = path.extname(transformFile);
            tmp.file({ prefix: 'jscodeshift', postfix: ext }, (err, path, fd) => {
              if (err) return reject(err);
              fs.write(fd, contents, function (err) {
                if (err) return reject(err);
                fs.close(fd, function(err) {
                  if (err) return reject(err);
                  transform(path).then(resolve, reject);
                });
              });
            });
        })
      })
      .on('error', (e) => {
        reject(e);
      });
    });
  } else if (!fs.existsSync(transformFile)) {
    process.stderr.write(
      pc.bgRed(pc.white('ERROR')) + ' Transform file ' + transformFile + ' does not exist \n'
    );
    return;
  } else {
    return transform(transformFile);
  }

  function transform(transformFile) {
    return getAllFiles(
      paths,
      name => !extensions || extensions.indexOf(path.extname(name)) != -1
    ).then(files => {
        const numFiles = files.length;

        if (numFiles === 0) {
          process.stdout.write('No files selected, nothing to do. \n');
          return [];
        }

        const processes = options.runInBand ? 1 : Math.min(numFiles, cpus);
        const chunkSize = processes > 1 ?
          Math.min(Math.ceil(numFiles / processes), CHUNK_SIZE) :
          numFiles;

        let index = 0;
        // return the next chunk of work for a free worker
        function next() {
          if (!options.silent && !options.runInBand && index < numFiles) {
            process.stdout.write(
              'Sending ' +
              Math.min(chunkSize, numFiles-index) +
              ' files to free worker...\n'
            );
          }
          return files.slice(index, index += chunkSize);
        }

        if (!options.silent) {
          process.stdout.write('Processing ' + files.length + ' files... \n');
          if (!options.runInBand) {
            process.stdout.write(
              'Spawning ' + processes +' workers...\n'
            );
          }
          if (options.dry) {
            process.stdout.write(
              pc.green('Running in dry mode, no files will be written! \n')
            );
          }
        }

        const args = [transformFile, options.babel ? 'babel' : 'no-babel'];

        const workers = [];
        for (let i = 0; i < processes; i++) {
          workers.push(options.runInBand ?
            require('./Worker')(args) :
            child_process.fork(require.resolve('./Worker'), args)
          );
        }

        return workers.map(child => {
          child.send({files: next(), options});
          child.on('message', message => {
            switch (message.action) {
              case 'status':
                fileCounters[message.status] += 1;
                log[message.status](lineBreak(message.msg), options.verbose);
                break;
              case 'update':
                if (!statsCounter[message.name]) {
                  statsCounter[message.name] = 0;
                }
                statsCounter[message.name] += message.quantity;
                break;
              case 'free':
                child.send({files: next(), options});
                break;
              case 'report':
                report(message);
                break;
            }
          });
          return new Promise(resolve => child.on('disconnect', resolve));
        });
      })
      .then(pendingWorkers =>
        Promise.all(pendingWorkers).then(() => {
          const endTime = process.hrtime(startTime);
          const timeElapsed = (endTime[0] + endTime[1]/1e9).toFixed(3);
          if (!options.silent) {
            process.stdout.write('All done. \n');
            showFileStats(fileCounters);
            showStats(statsCounter);
            process.stdout.write(
              'Time elapsed: ' + timeElapsed + 'seconds \n'
            );

            if (options.failOnError && fileCounters.error > 0) {
              process.exit(1);
            }
          }
          return Object.assign({
            stats: statsCounter,
            timeElapsed: timeElapsed
          }, fileCounters);
        })
      );
  }
}

exports.run = run;
