#!/usr/bin/env node

var path = require('path');
var assert = require('assert');
var fs = require('fs-extra');
var glob = require('glob');
var _ = require('lodash');
var request = require('superagent');
var async = require('async');
var tarball = require('tarball-extract');
var chmodr = require('chmodr');
var colors = require('colors');
var isThere = require('is-there');
var stable = require('semver-stable');
var semver = require('semver');
var tempDirPath;
var args;

if (fs.existsSync('/run/shm')) {
  tempDirPath = '/run/shm/' + process.env.USER + '/cdnjs_NPM_temp';
} else {
  tempDirPath = path.join(__dirname, 'temp');
}

colors.setTheme({
  prompt: 'cyan',
  info: 'grey',
  success: 'green',
  warn: 'yellow',
  error: 'red'
});

var newVersionCount = 0;
var parse = function (jsonFile, ignoreMissing, ignoreParseFail) {
  var content;

  try {
    content = fs.readFileSync(jsonFile, 'utf8');
  } catch (err1) {
    if (!ignoreMissing) {
      assert.ok(0, jsonFile + " doesn't exist!");
    }

    return null;
  }

  try {
    return JSON.parse(content);
  } catch (err2) {
    if (!ignoreParseFail) {
      // assert.ok(0, jsonFile + " failed to parse");
    }

    return null;
  }
};

var reEscape = function (s) {
  return s.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
};

/**
 * Check if an npmFileMap object contains any path which are not normalized, and thus could allow access to parent dirs
 * @param pkg
 * @returns {*}
 */
var isValidFileMap = function (pkg) {
  var isValidPath = function (p) {
    if (p !== null) { // don't allow parent dir access, or tricky paths
      p = p.replace(/\/+/g, '/'); // don't penalize for consequtive path seperators
      return p === path.normalize(p);
    }

    return false;
  };

  if (pkg && pkg.npmFileMap) {
    return _.every(pkg.npmFileMap, function (fileSpec) {
      if (isValidPath(fileSpec.basePath || '/')) {
        return _.every(fileSpec.files, isValidPath);
      }

      return false;
    });
  }

  return false;
};

var error = function (msg, name) {
  var err = new Error(msg);
  err.name = name;
  console.log(msg.error);
  return err;
};

error.PKG_NAME = 'BadPackageName';
error.FILE_PATH = 'BadFilePath';

/**
 * returns a fucntion that takes N args, where each arg is a path that must not outside of libPath.
 * returns true if all paths are within libPath, else false
 */
var isAllowedPathFn = function (libPath) { // is path within the lib dir? if not, they shouldnt be writing/reading there
  libPath = path.normalize(libPath || '/');
  return function () {
    var paths = arguments.length >= 1 ? [].slice.call(arguments, 0) : [];
    var re = new RegExp('^' + reEscape(libPath));
    return _.every(paths, function (p) {
      p = path.normalize(p);
      return p.match(re);
    });
  };
};

var invalidNpmName = function (name) {
  return (name.indexOf('..') !== -1); // doesnt contain
};

var getPackagePath = function (pkg, version) {
  return path.normalize(path.join(__dirname, 'ajax', 'libs', pkg.name, version));
};

var getPackageTempPath = function (pkg, version) {
  return path.normalize(path.join(tempDirPath, pkg.name, version));
};

/**
 * Attempt to update the npmFileMap from extracted package.json, then using npmFileMap move required files to libPath/../
 * If the npmFileMap tries to modify files outside of libPath, dont let it!
 * @param pkg
 * @param libPath = root folder for extracted lib
 * @returns {Array} = array of security related errors triggered during operation.
 */
var processNewVersion = function (pkg, version) {
  // sometimes the tar is extracted to a dir that isnt called 'package' - get that dir via glob
  var extractLibPath = glob.sync(getPackageTempPath(pkg, version) + '/*/')[0];

  if (!extractLibPath) {
    // even more rarely, the tar doesnt seem to get extracted at all.. which is probably a bug in that lib.
    var msg = pkg.npmName + '@' + version +
      ' - never got extracted! This problem usually goes away on next run.' +
      ' Couldnt find extract dir here: ' + getPackageTempPath(pkg, version);
    console.log(msg.error);
    return;
  }

  // trick to handle wrong permission lib like clipboard.js@0.0.7
  fs.chmodSync(extractLibPath, 0755);
  chmodr.sync(extractLibPath, 0755);

  var libPath = getPackagePath(pkg, version);
  var isAllowedPath = isAllowedPathFn(extractLibPath);
  var newPath = path.join(libPath, 'package.json');
  if (fs.existsSync(newPath)) { // turn this off for now
    var newPkg = parse(newPath);
    if (isValidFileMap(newPkg)) {
      pkg.npmFileMap = newPkg.npmFileMap;
    }
  }

  var npmFileMap = pkg.npmFileMap;
  var errors = [];
  var updated = false;
  _.each(npmFileMap, function (fileSpec) {
    var basePath = fileSpec.basePath || '';

    _.each(fileSpec.files, function (file) {
      var libContentsPath = path.normalize(path.join(extractLibPath, basePath));
      if (!isAllowedPath(libContentsPath)) {
        errors.push(error(pkg.npmName + ' contains a malicious file path: ' +
          libContentsPath, error.FILE_PATH));
        return;
      }

      var files = glob.sync(path.join(libContentsPath, file), { nodir: true });
      if (files.length === 0) {
        // usually old versions have this problem
        var msg;
        msg = (pkg.npmName + '@' + version + ' - couldnt find file in npmFileMap.') +
          (' Doesnt exist: ' + path.join(libContentsPath, file)).info;
        fs.mkdirsSync(libPath);
        console.log(msg);
      }

      _.each(files, function (extractFilePath) {
        if (extractFilePath.match(/(\.zip\s*$)/i)) {
          return;
        }

        var copyPart = path.relative(libContentsPath, extractFilePath);
        var copyPath = path.join(libPath, copyPart);
        if (fs.statSync(extractFilePath).size !== 0){
          // don't copy the empty file from the source
          fs.mkdirsSync(path.dirname(copyPath));
          fs.copySync(extractFilePath, copyPath);
          fs.chmodSync(copyPath, '0644');
        }
        updated = true;
      });
    });
  });

  if (updated) {
    newVersionCount++;
    var libPatha = path.normalize(path.join(__dirname, 'ajax', 'libs', pkg.name, 'package.json'));
    console.log('------------'.red, libPatha.green);
    if (
      (!pkg.version) ||
      (
        semver.gt(version, pkg.version) &&
        (
          stable.is(version) ||
          (!stable.is(version) && !stable.is(pkg.version))
        )
      )
    ) {
      pkg.version = version;
      fs.writeFileSync(libPatha, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
    }
  }

  return errors;
};

/**
 * download and extract a tarball for a single npm version, get the files in npmFileMap and delete the rest
 * @param pkg
 * @param tarballUrl
 * @param version
 * @param cb
 * @returns {*}
 */
var updateLibraryVersion = function (pkg, tarballUrl, version, cb) {
  if (invalidNpmName(pkg.name)) {
    return cb(error(pkg.npmName + ' has a malicious package name:' + pkg.name, error.PKG_NAME));
  }

  var extractLibPath = getPackageTempPath(pkg, version);
  var libPath = getPackagePath(pkg, version);

  if (fs.existsSync(libPath)) {
    cb();
  } else {
    fs.mkdirsSync(extractLibPath);
    var url = tarballUrl;
    var msg;
    var downloadFile = path.join(extractLibPath, 'dist.tar.gz');
    tarball.extractTarballDownload(url, downloadFile, extractLibPath, {}, function (err, result) {
      if (!err && fs.existsSync(downloadFile)) {
        msg = 'Found version ' + version + ' of ' +
          pkg.npmName + ', now try to import it.';
        console.log(msg.warn);
        processNewVersion(pkg, version);
      } else if (result.error === 'Server respond 404') {
        msg = 'Got 404 on version ' + version + ' of ' + pkg.npmName +
          ', create an empty folder for it.';
        fs.mkdirsSync('./ajax/libs/' + pkg.name + '/' + version);
        console.log(msg.warn);
      } else {
        msg = 'error downloading ' + version + ' of ' + pkg.npmName +
          ' it didnt exist: ' + result.error;
        console.log(msg.error);
      }

      cb();
    });
  }
};

/**
 * grab all versions of a lib that has an 'npmFileMap' and 'npmName' in its package.json
 * @param pkg
 * @param tarballUrl
 * @param cb
 */
var updateLibrary = function (pkg, cb) {
  var msg;
  if (!isValidFileMap(pkg)) {
    msg = pkg.npmName.error + ' has a malicious npmFileMap';
    console.log(msg.warn);
    return cb(null);
  }

  msg = 'Checking versions for ' + pkg.npmName;
  if (pkg.name !== pkg.npmName) {
    msg += ' (' + pkg.name + ')';
  }

  console.log(msg.prompt);
  var npmNameScopeReg = /^@.+\/.+$/;
  var scopedPackage = false;
  if (npmNameScopeReg.test(pkg.npmName)) {
    scopedPackage = pkg.npmName;
    pkg.npmName = pkg.npmName.replace('/', '%2f');
  }

  request.get('https://registry.npmjs.org/' + pkg.npmName).end(function (error, result) {
    if (scopedPackage !== false) {
      pkg.npmName = scopedPackage;
    }
    if (result !== undefined && result.body !== undefined) {
      async.each(_.toPairs(result.body.versions), function (p, cb) {
        var data = p[1];
        var version = p[0];
        updateLibraryVersion(pkg, data.dist.tarball, version, cb);
      }, function (err) {

        var msg = 'Library "' + pkg.name + '" update finished' +
          (err ? ' ' + err.error : '');
        console.log(msg);
        cb(null);
      });
    } else {
      console.log(('Got error on ' + pkg.name + ' ! Error: ' + error).error);
    }
  });
};

exports.run = function () {
  fs.removeSync(path.join(tempDirPath));
  fs.mkdirsSync(tempDirPath);

  console.log('Looking for npm enabled libraries...');

  // load up those files
  var packages;
  var globPattern = '*';
  if (args.length === 2) {
    globPattern = args[1];
  }

  packages = glob.sync('./ajax/libs/' + globPattern + '/package.json');
  packages = _(packages).map(function (pkg) {
    var parsedPkg = parse(pkg);
    return (parsedPkg.npmName && parsedPkg.npmFileMap) ? parsedPkg : null;
  }).compact().value();
  var msg = 'Found ' + packages.length + ' npm enabled libraries';
  console.log(msg.prompt);

  async.each(packages, updateLibrary, function (err) {
    var msg = 'Auto Update Completed - ' + newVersionCount +
      ' versions were updated';
    console.log(msg.prompt);
    if (err) {
      console.dir(err);
    }
  });
};

exports.updateLibrary = updateLibrary;
exports.updateLibraryVersion = updateLibraryVersion;
exports.processNewVersion = processNewVersion;
exports.error = error;
exports.isAllowedPathFn = isAllowedPathFn;
exports.isValidFileMap = isValidFileMap;
exports.invalidNpmName = invalidNpmName;

args = process.argv.slice(2);
if (args.length > 0 && args[0] === 'run') {
  exports.run();
} else {
  console.log('to start "npm auto-update", pass the "run" arg'.prompt);
}