var Gutil = require('gulp-util');
var Merge = require('merge');
var Path = require('path');
var Tool = require('./tool');

var Revisioner = (function () {
  'use strict';
  var Revisioner = function(options) {

    var defaults = {
      'hashLength': 8,
      'dontGlobal': [ /^\/favicon.ico$/g ],
      'dontRenameFile': [],
      'dontUpdateReference': [],
      'dontSearchFile': [],
      'fileNameVersion': 'rev-version.json',
      'fileNameManifest': 'rev-manifest.json',
      'prefix': '',
      'referenceToRegexs': referenceToRegexs,
      'annotator': annotator,
      'replacer': replacer,
      'debug': false,
      'includeFilesInManifest': ['.css', '.js']
    };

    this.options = Merge(defaults, options);

    // File pool, any file passed into the Revisioner is stored in this object
    this.files = {};
    this.filesTemp = [];

    // Stores the combined hash of all processed files, used to create the version file
    this.hashCombined = '';

    // Stores the before : after path of assets, used to create the manifset file
    this.manifest = {};

    // Enable / Disable logger based on supplied options
    this.log = (this.options.debug) ? Gutil.log : function () {};

    // Make tools available client side callbacks supplied in options
    this.Tool = Tool;


    var nonFileNameChar = '[^a-zA-Z0-9\\.\\-\\_\\/]';
    var qoutes = '\'|"';

    function referenceToRegexs(reference) {
      var escapedRefPathBase = Tool.path_without_ext(reference.path).replace(/([^0-9a-z])/ig, '\\$1');
      var escapedRefPathExt = Path.extname(reference.path).replace(/([^0-9a-z])/ig, '\\$1');

      var regExp, regExps = [];
      var isJSReference = reference.path.match(/\.js$/);

      // Extensionless javascript file references has to to be qouted
      if (isJSReference) {
        regExp = '('+ qoutes +')(' + escapedRefPathBase + ')()('+ qoutes + '|$)';
        regExps.push(new RegExp(regExp, 'g'));
      }

      // Expect left and right sides of the reference to be a non-filename type character, escape special regex chars
      regExp = '('+ nonFileNameChar +')(' + escapedRefPathBase + ')(' +  escapedRefPathExt + ')('+ nonFileNameChar + '|$)';
      regExps.push(new RegExp(regExp, 'g'));

      return regExps;
    }

    function annotator(contents, path) {
      return [{'contents': contents}];
    }

    function replacer(fragment, replaceRegExp, newReference, referencedFile) {
       fragment.contents = fragment.contents.replace(replaceRegExp, '$1' + newReference + '$3$4');
    }

  };

  Revisioner.prototype.versionFile = function () {

    var out = {
      hash: this.hashCombined,
      timestamp: new Date()
    };

    var file = new Gutil.File({
      cwd: this.pathCwd,
      base: this.pathBase,
      path: Path.join(this.pathBase, this.options.fileNameVersion),
      contents: new Buffer(JSON.stringify(out, null, 2)),
      revisioner: this
    });

    file.revisioner = this;
    return file;

  };

  Revisioner.prototype.manifestFile = function () {

    var file = new Gutil.File({
      cwd: this.pathCwd,
      base: this.pathBase,
      path: Path.join(this.pathBase, this.options.fileNameManifest),
      contents: new Buffer(JSON.stringify(this.manifest, null, 2)),
    });

    file.revisioner = this;
    return file;

  };

  /**
   * Used to feed files into the Revisioner, sets up the original filename and hash.
   */
  Revisioner.prototype.processFile = function (file) {

    if (!this.pathCwd) {
      this.pathCwd = file.cwd;
    }

    // Chnage relative paths to absolute
    if (!file.base.match(/^(\/|[a-z]:)/i)) {
      file.base = Tool.join_path(file.cwd, file.base);
    }

    // Normalize the base common to all the files
    if (!this.pathBase) {

      this.pathBase = file.base;

    } else if (file.base.indexOf(this.pathBase) === -1) {

      var levelsBase = this.pathBase.split(/[\/|\\]/);
      var levelsFile = file.base.split(/[\/|\\]/);

      var common = [];
      for (var level = 0, length = levelsFile.length; level < length; level++) {

        if (level < levelsBase.length && level < levelsFile.length &&
            levelsBase[level] === levelsFile[level]) {
          common.push(levelsFile[level]);
          continue;
        }
      }

      if (common[common.length - 1] !== '') {
        common.push('');
      }
      this.pathBase = common.join('/');

    }

    // Set original values before any processing occurs
    file.revPathOriginal = file.revOrigPath = file.path;
    file.revFilenameExtOriginal = Path.extname(file.path);
    file.revFilenameOriginal = Path.basename(file.path, file.revFilenameExtOriginal);
    file.revHashOriginal = this.Tool.md5(String(file.contents));
    file.revContentsOriginal = file.contents;

    this.filesTemp.push(file);

  };

  /**
   * Resolves references, renames files, updates references.  To be called after all the files
   * have been fed into the Revisioner (ie. At the end of the file stream)
   */
  Revisioner.prototype.run = function () {

    this.hashCombined = '';

    // Go through and correct the base path now that we have proccessed all the files coming in
    for (var i = 0, length = this.filesTemp.length; i < length; i++) {

      this.filesTemp[i].base = this.pathBase;
      var path = this.Tool.get_relative_path(this.pathBase, this.filesTemp[i].path);
      this.files[path] = this.filesTemp[i];

    }

    // Resolve references to other files
    for (var path in this.files) {
      this.resolveReferences(this.files[path]);
    }

    // Resolve and set revisioned filename based on hash + reference hashes and ignore rules
    for (var path in this.files) {
      this.revisionFilename(this.files[path]);
    }

    // Consolidate the concatinated hash of all the files, into a single hash for the version file
    this.hashCombined = this.Tool.md5(this.hashCombined);

    // Update references to revisioned filenames
    for (var path in this.files) {
      this.updateReferences(this.files[path]);
    }

  };

  /**
   * Go through each file in the file pool, search for references to any other file in the pool.
   */
  Revisioner.prototype.resolveReferences = function (fileResolveReferencesIn) {


    var contents = String(fileResolveReferencesIn.revContentsOriginal);
    fileResolveReferencesIn.revReferencePaths = {};
    fileResolveReferencesIn.revReferenceFiles = {};
    var referenceGroupRelative = [];
    var referenceGroupAbsolute = [];
    fileResolveReferencesIn.referenceGroupsContainer = {
      'relative': referenceGroupRelative,
      'absolute': referenceGroupAbsolute
    };

    // Don't try and resolve references in binary files or files that have been blacklisted
    if (this.Tool.is_binary_file(fileResolveReferencesIn) || !this.shouldSearchFile(fileResolveReferencesIn)) {
      return;
    }

    // For the current file (fileResolveReferencesIn), look for references to any other file in the project
    for (var path in this.files) {

      // Organize them by relative vs absolute reference types
      var fileCurrentReference = this.files[path];
      var references;

      references = this.Tool.get_reference_representations_relative(fileCurrentReference, fileResolveReferencesIn);
      for (var i = 0, length = references.length; i < length; i++) {
        referenceGroupRelative.push({
          'file': this.files[path],
          'path': references[i]
        });
      }

      references = this.Tool.get_reference_representations_absolute(fileCurrentReference, fileResolveReferencesIn);
      for (var i = 0, length = references.length; i < length; i++) {
        referenceGroupAbsolute.push({
          'file': this.files[path],
          'path': references[i]
        });
      }

    }

    // Priority relative references higher than absolute
    for (var referenceType in fileResolveReferencesIn.referenceGroupsContainer) {
      var referenceGroup = fileResolveReferencesIn.referenceGroupsContainer[referenceType];

      for (var referenceIndex = 0, referenceGroupLength = referenceGroup.length; referenceIndex < referenceGroupLength; referenceIndex++) {
        var reference = referenceGroup[referenceIndex];
        var regExps = this.options.referenceToRegexs(reference);

        for (var j = 0; j < regExps.length; j++) {
          if (contents.match(regExps[j])) {
            // Only register this reference if we don't have one already by the same path
            if (!fileResolveReferencesIn.revReferencePaths[reference.path]) {

              fileResolveReferencesIn.revReferenceFiles[reference.file.path] = reference.file;
              fileResolveReferencesIn.revReferencePaths[reference.path] = {
                'regExps': [regExps[j]],
                'file': reference.file,
                'path': reference.path
              };
              this.log('gulp-rev-all:', 'Found', referenceType, 'reference [', Gutil.colors.magenta(reference.path), '] -> [', Gutil.colors.green(reference.file.path), '] in [', Gutil.colors.blue(fileResolveReferencesIn.revPathOriginal), ']');
            } else if (fileResolveReferencesIn.revReferencePaths[reference.path].file.revPathOriginal === reference.file.revPathOriginal) {
              // Append the other regexes to account for inconsitent use
              fileResolveReferencesIn.revReferencePaths[reference.path].regExps.push(regExps[j]);
            } else {
              this.log('gulp-rev-all:', 'Possible ambiguous reference detected [', Gutil.colors.red(fileResolveReferencesIn.revReferencePaths[reference.path].path), ' (', fileResolveReferencesIn.revReferencePaths[reference.path].file.revPathOriginal, ')] <-> [', Gutil.colors.red(reference.path), '(', Gutil.colors.red(reference.file.revPathOriginal), ')]');
            }
          }
        }
      }
    }


  };

  /**
   * Calculate hash based contents and references.
   * hash = hash(file hash + hash(hash references 1 + hash reference N)..)
   */
  Revisioner.prototype.calculateHash = function (file, stack) {

    stack = stack || [];
    var hash = file.revHashOriginal;

    stack.push(file);

    // Resolve hash for child references
    if (Object.keys(file.revReferenceFiles).length > 0) {

      for (var key in file.revReferenceFiles) {

        // Prevent infinite loops caused by circular references, don't recurse if we've already encountered this file
        if (stack.indexOf(file.revReferenceFiles[key]) === -1) {
          hash += this.calculateHash(file.revReferenceFiles[key], stack);
        }

      }

      // This file's hash should change if any of its references will be prefixed.
      if (this.options.prefix &&
          Object.keys(file.referenceGroupsContainer.absolute).length) {
        hash += this.options.prefix;
      }

      // Consolidate many hashes into one
      hash = this.Tool.md5(hash);

    }

    return hash;

  };


  /**
   * Revision filename based on internal contents + references.
   */
  Revisioner.prototype.revisionFilename = function (file) {

    var filename = file.revFilenameOriginal;
    var ext = file.revFilenameExtOriginal;

    file.revHash = this.calculateHash(file);

    // Allow the client to transform the final filename
    if (this.options.transformFilename) {
      filename = this.options.transformFilename.call(this, file, file.revHash);
    } else {
      filename = filename + '.' + file.revHash.substr(0, this.options.hashLength) + ext;
    }

    file.revFilename = filename;
    // file.revFilenameNoExt = Tool.path_without_ext(file.revFilename);

    if (this.shouldFileBeRenamed(file)) {
      file.path = this.Tool.join_path(Path.dirname(file.path), filename);
    }

    // Maintain the combined hash used in version file
    this.hashCombined += file.revHash;

    // Maintain the manifset file
    var pathOriginal = this.Tool.get_relative_path(this.pathBase, file.revPathOriginal, true);
    var pathRevisioned = this.Tool.get_relative_path(file.base, file.path, true);
    // Add only specific file types to the manifest file
    if (this.options.includeFilesInManifest.indexOf(ext) !== -1) {
        this.manifest[pathOriginal] = pathRevisioned;
    }

    file.revPath = pathRevisioned;

  };

  /**
   * Update the contents of a file with the revisioned filenames of its references.
   */
  Revisioner.prototype.updateReferences = function (file) {

    // Don't try and update references in binary files or blacklisted files
    if (this.Tool.is_binary_file(file) || !this.shouldSearchFile(file)) {
      return;
    }

    var contents = String(file.revContentsOriginal);
    var annotatedContent = this.options.annotator(contents, file.revPathOriginal);

    for (var pathReference in file.revReferencePaths) {

      var reference = file.revReferencePaths[pathReference];

      // Replace regular filename with revisioned version
      var referencePath = reference.path.substr(0, reference.path.length - (reference.file.revFilenameOriginal.length + reference.file.revFilenameExtOriginal.length));
      var pathReferenceReplace = referencePath + reference.file.revFilename;


      if (this.options.transformPath) {
        // Transform path using client supplied transformPath callback,
        pathReferenceReplace = this.options.transformPath.call(this, pathReferenceReplace, reference.path, reference.file, file);
      } else if (this.options.prefix && pathReferenceReplace[0] === '/') {
        // Append with user supplied prefix
        pathReferenceReplace = this.Tool.join_path_url(this.options.prefix, pathReferenceReplace);
      }

      if (this.shouldUpdateReference(reference.file)) {
        // The extention should remain constant so we dont add extentions to references without extentions
        var noExtReplace = Tool.path_without_ext(pathReferenceReplace);

        for(var i = 0; i < annotatedContent.length; i++){
          for(var j = 0; j < reference.regExps.length; j++){
            this.options.replacer(annotatedContent[i], reference.regExps[j], noExtReplace, reference.file);
          }
        }
      }

    }

    contents = annotatedContent.map(function(annotation) { return annotation.contents; }).join('');
    file.contents = new Buffer(contents);

  };

  /**
   * Determines if a file should be renamed based on dontRenameFile supplied in options.
   */
  Revisioner.prototype.shouldFileBeRenamed = function (file) {

    var filename = this.Tool.get_relative_path(file.base, file.revPathOriginal);

    for (var i = this.options.dontGlobal.length; i--;) {
      var regex = (this.options.dontGlobal[i] instanceof RegExp) ? this.options.dontGlobal[i] : new RegExp(this.options.dontGlobal[i] + '$', 'ig');
      if (filename.match(regex)) {
        return false;
      }
    }

    for (var i = this.options.dontRenameFile.length; i--;) {
      var regex = (this.options.dontRenameFile[i] instanceof RegExp) ? this.options.dontRenameFile[i] : new RegExp(this.options.dontRenameFile[i] + '$', 'ig');
      if (filename.match(regex)) {
        return false;
      }
    }
    return true;

  };


  /**
   * Determines if a particular reference should be updated across assets based on dontUpdateReference supplied in options.
   */
  Revisioner.prototype.shouldUpdateReference = function (file) {

    var filename = this.Tool.get_relative_path(file.base, file.revPathOriginal);

    for (var i = this.options.dontGlobal.length; i--;) {
      var regex = (this.options.dontGlobal[i] instanceof RegExp) ? this.options.dontGlobal[i] : new RegExp(this.options.dontGlobal[i] + '$', 'ig');
      if (filename.match(regex)) {
        return false;
      }
    }

    for (var i = this.options.dontUpdateReference.length; i--;) {
      var regex = (this.options.dontUpdateReference[i] instanceof RegExp) ? this.options.dontUpdateReference[i] : new RegExp(this.options.dontUpdateReference[i] + '$', 'ig');
      if (filename.match(regex)) {
        return false;
      }
    }
    
    return true;

  };

  /**
   * Determines if a particular reference should be updated across assets based on dontUpdateReference supplied in options.
   */
  Revisioner.prototype.shouldSearchFile = function (file) {

    var filename = this.Tool.get_relative_path(file.base, file.revPathOriginal);

    for (var i = this.options.dontGlobal.length; i--;) {
      var regex = (this.options.dontGlobal[i] instanceof RegExp) ? this.options.dontGlobal[i] : new RegExp(this.options.dontGlobal[i] + '$', 'ig');
      if (filename.match(regex)) {
        return false;
      }
    }

    for (var i = this.options.dontSearchFile.length; i--;) {
      var regex = (this.options.dontSearchFile[i] instanceof RegExp) ? this.options.dontSearchFile[i] : new RegExp(this.options.dontSearchFile[i] + '$', 'ig');
      if (filename.match(regex)) {
        return false;
      }
    }
    return true;

  };

  return Revisioner;


})();

module.exports = Revisioner;
