var crypto = require('crypto');
var fs = require('fs');
var path = require('path');

var async = require('async');

/**
 * Filter a list of files by mtime.
 * @param {Array.<string>} paths List of file paths.
 * @param {Date} time The comparison time.
 * @param {number} tolerance Maximum time in milliseconds that the destination
 *     file is allowed to be newer than the source file to compensate for
 *     imprecisions in modification times in file systems.
 * @param {function(string, Date, function(boolean))} override Override.
 * @param {function(Err, Array.<string>)} callback Callback called with any
 *     error and a list of files that have mtimes newer than the provided time.
 */
var filterPathsByTime = exports.filterPathsByTime = function(paths, time,
    tolerance, override, callback) {
  async.map(paths, fs.stat, function(err, stats) {
    if (err) {
      return callback(err);
    }

    var olderPaths = [];
    var newerPaths = paths.filter(function(filePath, index) {
      var newer = stats[index].mtime - time > tolerance;
      if (!newer) {
        olderPaths.push(filePath);
      }
      return newer;
    });

    async.filter(olderPaths, function(filePath, include) {
      override(filePath, time, include);
    }, function(overrides) {
      callback(null, newerPaths.concat(overrides));
    });
  });
};


/**
 * Determine if any of the given files are newer than the provided time.
 * @param {Array.<string>} paths List of file paths.
 * @param {Date} time The comparison time.
 * @param {number} tolerance Maximum time in milliseconds that the destination
 *     file is allowed to be newer than the source file to compensate for
 *     imprecisions in modification times in file systems.
 * @param {function(string, Date, function(boolean))} override Override.
 * @param {function(Err, boolean)} callback Callback called with any error and
 *     a boolean indicating whether any one of the supplied files is newer than
 *     the comparison time.
 */
var anyNewer = exports.anyNewer = function(paths, time, tolerance, override,
    callback) {
  if (paths.length === 0) {
    process.nextTick(function() {
      callback(null, false);
    });
    return;
  }
  var complete = 0;
  function iterate() {
    fs.stat(paths[complete], function(err, stats) {
      if (err) {
        return callback(err);
      }

      var pathTime = stats.mtime.getTime();
      var comparisonTime = time.getTime();
      var difference = pathTime - comparisonTime;

      if (difference > tolerance) {
        return callback(null, true);
      } else {
        override(paths[complete], time, function(include) {
          if (include) {
            callback(null, true);
          } else {
            ++complete;
            if (complete >= paths.length) {
              return callback(null, false);
            }
            iterate();
          }
        });
      }
    });
  }
  iterate();
};


/**
 * Filter a list of file config objects by time.  Source files on the provided
 * objects are removed if they have not been modified since the provided time
 * or any dest file mtime for a dest file on the same object.
 * @param {Array.<Object>} files A list of Grunt file config objects.  These
 *     are returned from `grunt.task.normalizeMultiTaskFiles` and have a src
 *     property (Array.<string>) and an optional dest property (string).
 * @param {Date} previous Comparison time.
 * @param {number} tolerance Maximum time in milliseconds that the destination
 *     file is allowed to be newer than the source file to compensate for
 *     imprecisions in modification times in file systems.
 * @param {function(string, Date, function(boolean))} override Override.
 * @param {function(Error, Array.<Object>)} callback Callback called with
 *     modified file config objects.  Objects with no more src files are
 *     filtered from the results.
 */
var filterFilesByTime = exports.filterFilesByTime = function(files, previous,
    tolerance, override, callback) {
  async.map(files, function(obj, done) {
    if (obj.dest && !(obj.src.length === 1 && obj.dest === obj.src[0])) {
      fs.stat(obj.dest, function(err, stats) {
        if (err) {
          // dest file not yet created, use all src files
          return done(null, obj);
        }
        return anyNewer(
          obj.src, stats.mtime, tolerance, override, function(err, any) {
          done(err, any && obj);
        });
      });
    } else {
      filterPathsByTime(
          obj.src, previous, tolerance, override, function(err, src) {
        if (err) {
          return done(err);
        }
        done(null, {src: src, dest: obj.dest});
      });
    }
  }, function(err, results) {
    if (err) {
      return callback(err);
    }
    // get rid of file config objects with no src files
    callback(null, results.filter(function(obj) {
      return obj && obj.src && obj.src.length > 0;
    }));
  });
};


/**
 * Get path to cached file hash for a target.
 * @param {string} cacheDir Path to cache dir.
 * @param {string} taskName Task name.
 * @param {string} targetName Target name.
 * @param {string} filePath Path to file.
 * @return {string} Path to hash.
 */
var getHashPath = exports.getHashPath = function(cacheDir, taskName, targetName,
    filePath) {
  var hashedName = crypto.createHash('md5').update(filePath).digest('hex');
  return path.join(cacheDir, taskName, targetName, 'hashes', hashedName);
};


/**
 * Get an existing hash for a file (if it exists).
 * @param {string} filePath Path to file.
 * @param {string} cacheDir Cache directory.
 * @param {string} taskName Task name.
 * @param {string} targetName Target name.
 * @param {function(Error, string} callback Callback called with an error and
 *     file hash (or null if the file doesn't exist).
 */
var getExistingHash = exports.getExistingHash = function(filePath, cacheDir,
    taskName, targetName, callback) {
  var hashPath = getHashPath(cacheDir, taskName, targetName, filePath);
  fs.exists(hashPath, function(exists) {
    if (!exists) {
      return callback(null, null);
    }
    fs.readFile(hashPath, callback);
  });
};


/**
 * Generate a hash (md5sum) of a file contents.
 * @param {string} filePath Path to file.
 * @param {function(Error, string)} callback Callback called with any error and
 *     the hash of the file contents.
 */
var generateFileHash = exports.generateFileHash = function(filePath, callback) {
  var md5sum = crypto.createHash('md5');
  var stream = new fs.ReadStream(filePath);
  stream.on('data', function(chunk) {
    md5sum.update(chunk);
  });
  stream.on('error', callback);
  stream.on('end', function() {
    callback(null, md5sum.digest('hex'));
  });
};


/**
 * Filter files based on hashed contents.
 * @param {Array.<string>} paths List of paths to files.
 * @param {string} cacheDir Cache directory.
 * @param {string} taskName Task name.
 * @param {string} targetName Target name.
 * @param {function(Error, Array.<string>)} callback Callback called with any
 *     error and a filtered list of files that only includes files with hashes
 *     that are different than the cached hashes for the same files.
 */
var filterPathsByHash = exports.filterPathsByHash = function(paths, cacheDir,
    taskName, targetName, callback) {
  async.filter(paths, function(filePath, done) {
    async.parallel({
      previous: function(cb) {
        getExistingHash(filePath, cacheDir, taskName, targetName, cb);
      },
      current: function(cb) {
        generateFileHash(filePath, cb);
      }
    }, function(err, hashes) {
      if (err) {
        return callback(err);
      }
      done(String(hashes.previous) !== String(hashes.current));
    });
  }, callback);
};


/**
 * Filter a list of file config objects based on comparing hashes of src files.
 * @param {Array.<Object>} files List of file config objects.
 * @param {string} taskName Task name.
 * @param {string} targetName Target name.
 * @param {function(Error, Array.<Object>)} callback Callback called with a
 *     filtered list of file config objects.  Object returned will only include
 *     src files with hashes that are different than any cached hashes.  Config
 *     objects with no src files will be filtered from the list.
 */
var filterFilesByHash = exports.filterFilesByHash = function(files, taskName,
    targetName, callback) {
  var modified = false;
  async.map(files, function(obj, done) {

    filterPathsByHash(obj.src, taskName, targetName, function(err, src) {
      if (err) {
        return done(err);
      }
      if (src.length) {
        modified = true;
      }
      done(null, {src: src, dest: obj.dest});
    });

  }, function(err, newerFiles) {
    callback(err, newerFiles, modified);
  });
};


/**
 * Get the path to the cached timestamp for a target.
 * @param {string} cacheDir Path to cache dir.
 * @param {string} taskName Task name.
 * @param {string} targetName Target name.
 * @return {string} Path to timestamp.
 */
var getStampPath = exports.getStampPath = function(cacheDir, taskName,
    targetName) {
  return path.join(cacheDir, taskName, targetName, 'timestamp');
};
