#!/usr/bin/env node
;(function() {
  'use strict';

  /** Load Node modules */
  var fs = require('fs'),
      https = require('https'),
      path = require('path'),
      spawn = require('child_process').spawn,
      zlib = require('zlib'),
      tar = require('../vendor/tar/tar.js'),
      _ = require('../lodash.js');

  /** Load other modules */
  var preprocess = require('./pre-compile.js'),
      postprocess = require('./post-compile.js');

  /** The Git object ID of `closure-compiler.tar.gz` */
  var closureId = '23cf67d0f0b979d97631fc108a2a43bb82225994';

  /** The Git object ID of `uglifyjs.tar.gz` */
  var uglifyId = 'a934fb18f8fa2768c6a68de44b6e035fe96a268b';

  /** The path of the directory that is the base of the repository */
  var basePath = fs.realpathSync(path.join(__dirname, '..'));

  /** The path of the `vendor` directory */
  var vendorPath = path.join(basePath, 'vendor');

  /** The path to the Closure Compiler `.jar` */
  var closurePath = path.join(vendorPath, 'closure-compiler', 'compiler.jar');

  /** The path to the UglifyJS module */
  var uglifyPath = path.join(vendorPath, 'uglifyjs', 'tools', 'node.js');

  /** The Closure Compiler command-line options */
  var closureOptions = ['--warning_level=QUIET'];

  /** The media type for raw blob data */
  var mediaType = 'application/vnd.github.v3.raw';

  /** Used to reference parts of the blob href */
  var location = (function() {
    var host = 'api.github.com',
        origin = 'https://api.github.com',
        pathname = '/repos/bestiejs/lodash/git/blobs';

    return {
      'host': host,
      'href': origin + pathname,
      'origin': origin,
      'pathname': pathname
    };
  }());

  /** The Closure Compiler optimization modes */
  var optimizationModes = {
    'simple': 'SIMPLE_OPTIMIZATIONS',
    'advanced': 'ADVANCED_OPTIMIZATIONS'
  };

  /** Reassign `existsSync` for older versions of Node */
  fs.existsSync || (fs.existsSync = path.existsSync);

  /*--------------------------------------------------------------------------*/

  /**
   * Minifies a given Lo-Dash `source` and invokes the `options.onComplete`
   * callback when finished. The `onComplete` callback is invoked with one
   * argument; (outputSource).
   *
   * @param {Array|String} [source=''] The source to minify or array of commands.
   *  -o, --output - Write output to a given path/filename.
   *  -s, --silent - Skip status updates normally logged to the console.
   *  -t, --template - Applies template specific minifier options.
   *
   * @param {Object} [options={}] The options object.
   *  outputPath - Write output to a given path/filename.
   *  isSilent - Skip status updates normally logged to the console.
   *  isTemplate - Applies template specific minifier options.
   *  onComplete - The function called once minification has finished.
   */
  function minify(source, options) {
    // used to specify the source map URL
    var sourceMapURL;

    // used to specify the default minifer modes
    var modes = ['simple', 'advanced', 'hybrid'];

    source || (source = '');
    options || (options = {});

    // juggle arguments
    if (Array.isArray(source)) {
      // convert commands to an options object
      options = source;

      // used to report invalid command-line arguments
      var invalidArgs = _.reject(options.slice(options[0] == 'node' ? 2 : 0), function(value, index, options) {
        if (/^(?:-o|--output)$/.test(options[index - 1]) ||
            /^modes=.*$/.test(value)) {
          return true;
        }
        var result = [
          '-o', '--output',
          '-p', '--source-map',
          '-s', '--silent',
          '-t', '--template'
        ].indexOf(value) > -1;

        if (!result && /^(?:-p|--source-map)$/.test(options[index - 1])) {
          result = true;
          sourceMapURL = value;
        }
        return result;
      });

      // report invalid arguments
      if (invalidArgs.length) {
        console.log(
          '\n' +
          'Invalid argument' + (invalidArgs.length > 1 ? 's' : '') +
          ' passed: ' + invalidArgs.join(', ')
        );
        return;
      }
      var filePath = options[options.length - 1],
          isMapped = options.indexOf('-p') > -1 || options.indexOf('--source-map') > -1,
          isSilent = options.indexOf('-s') > -1 || options.indexOf('--silent') > -1,
          isTemplate = options.indexOf('-t') > -1 || options.indexOf('--template') > -1,
          outputPath = path.join(path.dirname(filePath), path.basename(filePath, '.js') + '.min.js');

      modes = options.reduce(function(result, value) {
        var match = value.match(/modes=(.*)$/);
        return match ? match[1].split(/, */) : result;
      }, modes);

      outputPath = options.reduce(function(result, value, index) {
        if (/-o|--output/.test(value)) {
          result = options[index + 1];
          result = path.join(fs.realpathSync(path.dirname(result)), path.basename(result));
        }
        return result;
      }, outputPath);

      options = {
        'filePath': filePath,
        'isMapped': isMapped,
        'isSilent': isSilent,
        'isTemplate': isTemplate,
        'modes': modes,
        'outputPath': outputPath,
        'sourceMapURL': sourceMapURL
      };

      source = fs.readFileSync(filePath, 'utf8');
    }

    modes = options.modes || modes;
    if (options.isMapped) {
      modes = modes.filter(function(mode) {
        return mode != 'hybrid';
      });
    }
    if (options.isTemplate) {
      modes = modes.filter(function(mode) {
        return mode != 'advanced';
      });
    }
    options.modes = modes;

    // fetch the Closure Compiler
    getDependency({
      'id': 'closure-compiler',
      'hashId': closureId,
      'path': vendorPath,
      'title': 'the Closure Compiler',
      'onComplete': function(exception) {
        var error = exception;

        // fetch UglifyJS
        getDependency({
          'id': 'uglifyjs',
          'hashId': uglifyId,
          'title': 'UglifyJS',
          'path': vendorPath,
          'onComplete': function(exception) {
            error || (error = exception);
            if (!error) {
              new Minify(source, options);
            }
          }
        });
      }
    });
  }

  /**
   * The Minify constructor used to keep state of each `minify` invocation.
   *
   * @private
   * @constructor
   * @param {String} source The source to minify.
   * @param {Object} options The options object.
   *  outputPath - Write output to a given path/filename.
   *  isSilent - Skip status updates normally logged to the console.
   *  isTemplate - Applies template specific minifier options.
   *  onComplete - The function called once minification has finished.
   */
  function Minify(source, options) {
    // juggle arguments
    if (typeof source == 'object' && source) {
      options = source || options;
      source = options.source || '';
    }
    this.compiled = { 'simple': {}, 'advanced': {} };
    this.hybrid = { 'simple': {}, 'advanced': {} };
    this.uglified = {};

    this.filePath = options.filePath;
    this.isMapped = !!options.isMapped;
    this.isSilent = !!options.isSilent;
    this.isTemplate = !!options.isTemplate;
    this.outputPath = options.outputPath;
    this.sourceMapURL = options.sourceMapURL;

    var modes = this.modes = options.modes;
    source = this.source = preprocess(source, options);

    this.onComplete = options.onComplete || function(data) {
      var outputPath = this.outputPath,
          sourceMap = data.sourceMap;

      fs.writeFileSync(outputPath, data.source, 'utf8');
      if (sourceMap) {
        fs.writeFileSync(getMapPath(outputPath), sourceMap, 'utf8');
      }
    };

    // begin the minification process
    if (modes.indexOf('simple') > -1) {
      closureCompile.call(this, source, 'simple', onClosureSimpleCompile.bind(this));
    } else if (modes.indexOf('advanced') > -1) {
      onClosureSimpleGzip.call(this);
    } else {
      onClosureAdvancedGzip.call(this);
    }
  }

  /*--------------------------------------------------------------------------*/

  /**
   * Fetches a required `.tar.gz` dependency with the given Git object ID from
   * the Lo-Dash repo on GitHub. The object ID may be obtained by running
   * `git hash-object path/to/dependency.tar.gz`.
   *
   * @private
   * @param {Object} options The options object.
   *  id - The Git object ID of the `.tar.gz` file.
   *  onComplete - The function called once the extraction has finished.
   *  path - The path of the extraction directory.
   *  title - The dependency's title used in status updates logged to the console.
   */
  function getDependency(options) {
    options || (options = {});

    var ran,
        destPath = options.path,
        hashId = options.hashId,
        id = options.id,
        onComplete = options.onComplete,
        title = options.title;

    // exit early if dependency exists
    if (fs.existsSync(path.join(destPath, id))) {
      onComplete();
      return;
    }
    var callback = function(exception) {
      if (ran) {
        return;
      }
      if (exception) {
        console.error([
          'There was a problem installing ' + title + '.',
          'Try running the command as root, via `sudo`, or manually install by running:',
          '',
          "curl -H 'Accept: " + mediaType + "' " + location.href + '/' + hashId + " | tar xvz -C '" + destPath + "'",
          ''
        ].join('\n'));
      }
      ran = true;
      process.removeListener('uncaughtException', callback);
      onComplete(exception);
    };

    console.log('Downloading ' + title + '...');
    process.on('uncaughtException', callback);

    https.get({
      'host': location.host,
      'path': location.pathname + '/' + hashId,
      'headers': {
        // By default, all GitHub blob API endpoints return a JSON document
        // containing Base64-encoded blob data. Overriding the `Accept` header
        // with the GitHub raw media type returns the blob data directly.
        // See http://developer.github.com/v3/media/.
        'Accept': mediaType
      }
    }, function(response) {
      var decompressor = zlib.createUnzip(),
          parser = new tar.Extract({ 'path': destPath });

      parser.on('end', callback);
      response.pipe(decompressor).pipe(parser);
    });
  }

  /**
   * Resolves the source map path from the given output path.
   *
   * @private
   * @param {String} outputPath The output path.
   * @returns {String} Returns the source map path.
   */
  function getMapPath(outputPath) {
    return path.join(path.dirname(outputPath), path.basename(outputPath, '.js') + '.map');
  }

  /*--------------------------------------------------------------------------*/

  /**
   * Compresses a `source` string using the Closure Compiler. Yields the
   * minified result, and any exceptions encountered, to a `callback` function.
   *
   * @private
   * @param {String} source The JavaScript source to minify.
   * @param {String} mode The optimization mode.
   * @param {Function} callback The function called once the process has completed.
   */
  function closureCompile(source, mode, callback) {
    var filePath = this.filePath,
        isAdvanced = mode == 'advanced',
        isMapped = this.isMapped,
        options = closureOptions.slice(),
        outputPath = this.outputPath,
        mapPath = getMapPath(outputPath),
        sourceMapURL = this.sourceMapURL || path.basename(mapPath);

    // remove copyright header to make other modifications easier
    var license = (/^(?:\s*\/\/.*\s*|\s*\/\*[^*]*\*+(?:[^\/][^*]*\*+)*\/\s*)*/.exec(source) || [''])[0];
    if (license) {
      source = source.replace(license, '');
    }

    var hasIIFE = /^;?\(function[^{]+{\s*/.test(source),
        isStrict = hasIIFE && /^;?\(function[^{]+{\s*["']use strict["']/.test(source);

    // to avoid stripping the IIFE, convert it to a function call
    if (hasIIFE && isAdvanced) {
      source = source
        .replace(/\(function/, '__iife__$&')
        .replace(/\(this\)\)([\s;]*(\n\/\/.+)?)$/, ', this)$1');
    }

    options.push('--compilation_level=' + optimizationModes[mode]);
    if (isMapped) {
      options.push('--create_source_map=' + mapPath, '--source_map_format=V3');
    }

    var compiler = spawn('java', ['-jar', closurePath].concat(options));
    if (!this.isSilent) {
      console.log('Compressing ' + path.basename(outputPath, '.js') + ' using the Closure Compiler (' + mode + ')...');
    }

    var error = '';
    compiler.stderr.on('data', function(data) {
      error += data;
    });

    var output = '';
    compiler.stdout.on('data', function(data) {
      output += data;
    });

    compiler.on('exit', function(status) {
      // `status` contains the process exit code
      if (status) {
        var exception = new Error(error);
        exception.status = status;
      }
      // restore IIFE and move exposed vars inside the IIFE
      if (hasIIFE && isAdvanced) {
        output = output
          .replace(/__iife__\(/, '(')
          .replace(/,\s*this\)([\s;]*(\n\/\/.+)?)$/, '(this))$1')
          .replace(/^((?:var (?:\w+=(?:!0|!1|null)[,;])+)?)([\s\S]*?function[^{]+{)/, '$2$1');
      }
      // inject "use strict" directive
      if (isStrict) {
        output = output.replace(/^[\s\S]*?function[^{]+{/, '$&"use strict";');
      }
      // restore copyright header
      if (license) {
        output = license + output;
      }
      if (isMapped) {
        var mapOutput = fs.readFileSync(mapPath, 'utf8');
        fs.unlinkSync(mapPath);
        output = output.replace(/[\s;]*$/, '\n/*\n//@ sourceMappingURL=' + sourceMapURL) + '\n*/';

        mapOutput = JSON.parse(mapOutput);
        mapOutput.file = path.basename(outputPath);
        mapOutput.sources = [path.basename(filePath)];
        mapOutput = JSON.stringify(mapOutput, null, 2);
      }
      callback(exception, output, mapOutput);
    });

    // proxy the standard input to the Closure Compiler
    compiler.stdin.end(source);
  }

  /**
   * Compresses a `source` string using UglifyJS. Yields the result to a
   * `callback` function. This function is synchronous; the `callback` is used
   * for symmetry.
   *
   * @private
   * @param {String} source The JavaScript source to minify.
   * @param {String} label The label to log.
   * @param {Function} callback The function called once the process has completed.
   */
  function uglify(source, label, callback) {
    if (!this.isSilent) {
      console.log('Compressing ' + path.basename(this.outputPath, '.js') + ' using ' + label + '...');
    }
    try {
      var uglifyJS = require(uglifyPath);

      // 1. parse
      var toplevel = uglifyJS.parse(source);

      // 2. compress
      // enable unsafe comparisons
      toplevel.figure_out_scope();
      toplevel = toplevel.transform(uglifyJS.Compressor({
        'comparisons': false,
        'unsafe_comps': true,
        'warnings': false
      }));

      // 3. mangle
      // excluding the `define` function exposed by AMD loaders
      toplevel.figure_out_scope();
      toplevel.compute_char_frequency();
      toplevel.mangle_names({
        'except': ['define']
      });

      // 4. output
      // restrict lines to 500 characters for consistency with the Closure Compiler
      var stream = uglifyJS.OutputStream({
        'ascii_only': true,
        'comments': /@cc_on|@license|@preserve/i,
        'max_line_len': 500,
      });

      toplevel.print(stream);
    }
    catch(e) {
      var exception = e;
    }
    callback(exception, stream && String(stream));
  }

  /*--------------------------------------------------------------------------*/

  /**
   * The Closure Compiler callback for simple optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {String} result The resulting minified source.
   * @param {String} map The source map output.
   */
  function onClosureSimpleCompile(exception, result, map) {
    if (exception) {
      throw exception;
    }
    result = postprocess(result);

    var simple = this.compiled.simple;
    simple.source = result;
    simple.sourceMap = map;
    zlib.gzip(result, onClosureSimpleGzip.bind(this));
  }

  /**
   * The Closure Compiler `gzip` callback for simple optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {Buffer} result The resulting gzipped source.
   */
  function onClosureSimpleGzip(exception, result) {
    if (exception) {
      throw exception;
    }
    if (result != null) {
      if (!this.isSilent) {
        console.log('Done. Size: %d bytes.', result.length);
      }
      this.compiled.simple.gzip = result;
    }
    // compile the source using advanced optimizations
    if (this.modes.indexOf('advanced') > -1) {
      closureCompile.call(this, this.source, 'advanced', onClosureAdvancedCompile.bind(this));
    } else {
      onClosureAdvancedGzip.call(this);
    }
  }

  /**
   * The Closure Compiler callback for advanced optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {String} result The resulting minified source.
   * @param {String} map The source map output.
   */
  function onClosureAdvancedCompile(exception, result, map) {
    if (exception) {
      throw exception;
    }
    result = postprocess(result);

    var advanced = this.compiled.advanced;
    advanced.source = result;
    advanced.sourceMap = map;
    zlib.gzip(result, onClosureAdvancedGzip.bind(this));
  }

  /**
   * The Closure Compiler `gzip` callback for advanced optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {Buffer} result The resulting gzipped source.
   */
  function onClosureAdvancedGzip(exception, result) {
    if (exception) {
      throw exception;
    }
    if (result != null) {
      if (!this.isSilent) {
        console.log('Done. Size: %d bytes.', result.length);
      }
      this.compiled.advanced.gzip = result;
    }
    // minify the source using UglifyJS
    if (!this.isMapped) {
      uglify.call(this, this.source, 'UglifyJS', onUglify.bind(this));
    } else {
      onComplete.call(this);
    }
  }

  /**
   * The UglifyJS callback.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {String} result The resulting minified source.
   */
  function onUglify(exception, result) {
    if (exception) {
      throw exception;
    }
    result = postprocess(result);
    this.uglified.source = result;
    zlib.gzip(result, onUglifyGzip.bind(this));
  }

  /**
   * The UglifyJS `gzip` callback.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {Buffer} result The resulting gzipped source.
   */
  function onUglifyGzip(exception, result) {
    if (exception) {
      throw exception;
    }
    if (result != null) {
      if (!this.isSilent) {
        console.log('Done. Size: %d bytes.', result.length);
      }
      this.uglified.gzip = result;
    }
    // minify the already Closure Compiler simple optimized source using UglifyJS
    var modes = this.modes;
    if (modes.indexOf('hybrid') > -1) {
      if (modes.indexOf('simple') > -1) {
        uglify.call(this, this.compiled.simple.source, 'hybrid (simple)', onSimpleHybrid.bind(this));
      } else if (modes.indexOf('advanced') > -1) {
        onSimpleHybridGzip.call(this);
      }
    } else {
      onComplete.call(this);
    }
  }

  /**
   * The hybrid callback for simple optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {String} result The resulting minified source.
   */
  function onSimpleHybrid(exception, result) {
    if (exception) {
      throw exception;
    }
    result = postprocess(result);
    this.hybrid.simple.source = result;
    zlib.gzip(result, onSimpleHybridGzip.bind(this));
  }

  /**
   * The hybrid `gzip` callback for simple optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {Buffer} result The resulting gzipped source.
   */
  function onSimpleHybridGzip(exception, result) {
    if (exception) {
      throw exception;
    }
    if (result != null) {
      if (!this.isSilent) {
        console.log('Done. Size: %d bytes.', result.length);
      }
      this.hybrid.simple.gzip = result;
    }
    // minify the already Closure Compiler advance optimized source using UglifyJS
    if (this.modes.indexOf('advanced') > -1) {
      uglify.call(this, this.compiled.advanced.source, 'hybrid (advanced)', onAdvancedHybrid.bind(this));
    } else {
      onComplete.call(this);
    }
  }

  /**
   * The hybrid callback for advanced optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {String} result The resulting minified source.
   */
  function onAdvancedHybrid(exception, result) {
    if (exception) {
      throw exception;
    }
    result = postprocess(result);
    this.hybrid.advanced.source = result;
    zlib.gzip(result, onAdvancedHybridGzip.bind(this));
  }

  /**
   * The hybrid `gzip` callback for advanced optimizations.
   *
   * @private
   * @param {Object|Undefined} exception The error object.
   * @param {Buffer} result The resulting gzipped source.
   */
  function onAdvancedHybridGzip(exception, result) {
    if (exception) {
      throw exception;
    }
    if (result != null) {
      if (!this.isSilent) {
        console.log('Done. Size: %d bytes.', result.length);
      }
      this.hybrid.advanced.gzip = result;
    }
    // finish by choosing the smallest compressed file
    onComplete.call(this);
  }

  /**
   * The callback executed after the source is minified and gzipped.
   *
   * @private
   */
  function onComplete() {
    var compiledSimple = this.compiled.simple,
        compiledAdvanced = this.compiled.advanced,
        uglified = this.uglified,
        hybridSimple = this.hybrid.simple,
        hybridAdvanced = this.hybrid.advanced;

    var objects = [
      compiledSimple,
      compiledAdvanced,
      uglified,
      hybridSimple,
      hybridAdvanced
    ];

    var gzips = objects
      .map(function(data) { return data.gzip; })
      .filter(Boolean);

    // select the smallest gzipped file and use its minified counterpart as the
    // official minified release (ties go to the Closure Compiler)
    var min = gzips.reduce(function(min, gzip) {
      var length = gzip.length;
      return min > length ? length : min;
    }, Infinity);

    // pass the minified source to the "onComplete" callback
    objects.some(function(data) {
      var gzip = data.gzip;
      if (gzip && gzip.length == min) {
        data.outputPath = this.outputPath;
        this.onComplete(data);
        return true;
      }
    }, this);
  }

  /*--------------------------------------------------------------------------*/

  // expose `minify`
  if (module != require.main) {
    module.exports = minify;
  }
  else {
    // read the Lo-Dash source file from the first argument if the script
    // was invoked directly (e.g. `node minify.js source.js`) and write to
    // `<filename>.min.js`
    (function() {
      var options = process.argv;
      if (options.length < 3) {
        return;
      }
      minify(options);
    }());
  }
}());
