// File List
// =========
//
// The List is an object for tracking all files that karma knows about
// currently.

// Dependencies
// ------------

require('core-js')
var from = require('core-js/library/fn/array/from')
var Promise = require('bluebird')
var mm = require('minimatch')
var Glob = require('glob').Glob
var fs = Promise.promisifyAll(require('graceful-fs'))
var pathLib = require('path')
var _ = require('lodash')

var File = require('./file')
var Url = require('./url')
var helper = require('./helper')
var log = require('./logger').create('watcher')
var createPatternObject = require('./config').createPatternObject

// Constants
// ---------

var GLOB_OPTS = {
  cwd: '/',
  follow: true,
  nodir: true,
  sync: true
}

// Helper Functions
// ----------------

function byPath (a, b) {
  if (a.path > b.path) return 1
  if (a.path < b.path) return -1

  return 0
}

// Constructor
//
// patterns      - Array
// excludes      - Array
// emitter       - EventEmitter
// preprocess    - Function
// batchInterval - Number
var List = function (patterns, excludes, emitter, preprocess, batchInterval) {
  // Store options
  this._patterns = patterns
  this._excludes = excludes
  this._emitter = emitter
  this._preprocess = Promise.promisify(preprocess)
  this._batchInterval = batchInterval

  // The actual list of files
  this.buckets = new Map()

  // Internal tracker if we are refreshing.
  // When a refresh is triggered this gets set
  // to the promise that `this._refresh` returns.
  // So we know we are refreshing when this promise
  // is still pending, and we are done when it's either
  // resolved or rejected.
  this._refreshing = Promise.resolve()

  var self = this

  // Emit the `file_list_modified` event.
  // This function is throttled to the value of `batchInterval`
  // to avoid spamming the listener.
  function emit () {
    self._emitter.emit('file_list_modified', self.files)
  }
  var throttledEmit = _.throttle(emit, self._batchInterval, {leading: false})
  self._emitModified = function (immediate) {
    immediate ? emit() : throttledEmit()
  }
}

// Private Interface
// -----------------

// Is the given path matched by any exclusion filter
//
// path - String
//
// Returns `undefined` if no match, otherwise the matching
// pattern.
List.prototype._isExcluded = function (path) {
  return _.find(this._excludes, function (pattern) {
    return mm(path, pattern)
  })
}

// Find the matching include pattern for the given path.
//
// path - String
//
// Returns the match or `undefined` if none found.
List.prototype._isIncluded = function (path) {
  return _.find(this._patterns, function (pattern) {
    return mm(path, pattern.pattern)
  })
}

// Find the given path in the bucket corresponding
// to the given pattern.
//
// path    - String
// pattern - Object
//
// Returns a File or undefined
List.prototype._findFile = function (path, pattern) {
  if (!path || !pattern) return
  if (!this.buckets.has(pattern.pattern)) return

  return _.find(from(this.buckets.get(pattern.pattern)), function (file) {
    return file.originalPath === path
  })
}

// Is the given path already in the files list.
//
// path - String
//
// Returns a boolean.
List.prototype._exists = function (path) {
  var self = this

  var patterns = this._patterns.filter(function (pattern) {
    return mm(path, pattern.pattern)
  })

  return !!_.find(patterns, function (pattern) {
    return self._findFile(path, pattern)
  })
}

// Check if we are currently refreshing
List.prototype._isRefreshing = function () {
  return this._refreshing.isPending()
}

// Do the actual work of refreshing
List.prototype._refresh = function () {
  var self = this
  var buckets = this.buckets

  var promise = Promise.map(this._patterns, function (patternObject) {
    var pattern = patternObject.pattern

    if (helper.isUrlAbsolute(pattern)) {
      buckets.set(pattern, new Set([new Url(pattern)]))
      return Promise.resolve()
    }

    var mg = new Glob(pathLib.normalize(pattern), GLOB_OPTS)
    var files = mg.found
    buckets.set(pattern, new Set())

    if (_.isEmpty(files)) {
      log.warn('Pattern "%s" does not match any file.', pattern)
      return
    }

    return Promise.map(files, function (path) {
      if (self._isExcluded(path)) {
        log.debug('Excluded file "%s"', path)
        return Promise.resolve()
      }

      var mtime = mg.statCache[path].mtime
      var doNotCache = patternObject.nocache
      var file = new File(path, mtime, doNotCache)

      if (file.doNotCache) {
        log.debug('Not preprocessing "%s" due to nocache')
        return Promise.resolve(file)
      }

      return self._preprocess(file).then(function () {
        return file
      })
    })
    .then(function (files) {
      files = _.compact(files)

      if (_.isEmpty(files)) {
        log.warn('All files matched by "%s" were excluded.', pattern)
      } else {
        buckets.set(pattern, new Set(files))
      }
    })
  })
  .then(function () {
    if (self._refreshing !== promise) {
      return self._refreshing
    }
    self.buckets = buckets
    self._emitModified(true)
    return self.files
  })

  return promise
}

// Public Interface
// ----------------

Object.defineProperty(List.prototype, 'files', {
  get: function () {
    var self = this
    var uniqueFlat = function (list) {
      return _.uniq(_.flatten(list), 'path')
    }

    var expandPattern = function (p) {
      return from(self.buckets.get(p.pattern) || []).sort(byPath)
    }

    var served = this._patterns.filter(function (pattern) {
      return pattern.served
    })
    .map(expandPattern)

    var lookup = {}
    var included = {}
    this._patterns.forEach(function (p) {
      // This needs to be here sadly, as plugins are modifiying
      // the _patterns directly resulting in elements not being
      // instantiated properly
      if (p.constructor.name !== 'Pattern') {
        p = createPatternObject(p)
      }

      var bucket = expandPattern(p)
      bucket.forEach(function (file) {
        var other = lookup[file.path]
        if (other && other.compare(p) < 0) return
        lookup[file.path] = p
        if (p.included) {
          included[file.path] = file
        } else {
          delete included[file.path]
        }
      })
    })

    return {
      served: uniqueFlat(served),
      included: _.values(included)
    }
  }
})

// Reglob all patterns to update the list.
//
// Returns a promise that is resolved when the refresh
// is completed.
List.prototype.refresh = function () {
  this._refreshing = this._refresh()
  return this._refreshing
}

// Set new patterns and excludes and update
// the list accordingly
//
// patterns - Array, the new patterns.
// excludes - Array, the new exclude patterns.
//
// Returns a promise that is resolved when the refresh
// is completed.
List.prototype.reload = function (patterns, excludes) {
  this._patterns = patterns
  this._excludes = excludes

  // Wait until the current refresh is done and then do a
  // refresh to ensure a refresh actually happens
  return this.refresh()
}

// Add a new file from the list.
// This is called by the watcher
//
// path - String, the path of the file to update.
//
// Returns a promise that is resolved when the update
// is completed.
List.prototype.addFile = function (path) {
  var self = this

  // Ensure we are not adding a file that should be excluded
  var excluded = this._isExcluded(path)
  if (excluded) {
    log.debug('Add file "%s" ignored. Excluded by "%s".', path, excluded)

    return Promise.resolve(this.files)
  }

  var pattern = this._isIncluded(path)

  if (!pattern) {
    log.debug('Add file "%s" ignored. Does not match any pattern.', path)
    return Promise.resolve(this.files)
  }

  if (this._exists(path)) {
    log.debug('Add file "%s" ignored. Already in the list.', path)
    return Promise.resolve(this.files)
  }

  var file = new File(path)
  this.buckets.get(pattern.pattern).add(file)

  return Promise.all([
    fs.statAsync(path),
    this._refreshing
  ]).spread(function (stat) {
    file.mtime = stat.mtime
    return self._preprocess(file)
  })
  .then(function () {
    log.info('Added file "%s".', path)
    self._emitModified()
    return self.files
  })
}

// Update the `mtime` of a file.
// This is called by the watcher
//
// path - String, the path of the file to update.
//
// Returns a promise that is resolved when the update
// is completed.
List.prototype.changeFile = function (path) {
  var self = this

  var pattern = this._isIncluded(path)
  var file = this._findFile(path, pattern)

  if (!pattern || !file) {
    log.debug('Changed file "%s" ignored. Does not match any file in the list.', path)
    return Promise.resolve(this.files)
  }

  return Promise.all([
    fs.statAsync(path),
    this._refreshing
  ]).spread(function (stat) {
    if (stat.mtime <= file.mtime) throw new Promise.CancellationError()

    file.mtime = stat.mtime
    return self._preprocess(file)
  })
  .then(function () {
    log.info('Changed file "%s".', path)
    self._emitModified()
    return self.files
  })
  .catch(Promise.CancellationError, function () {
    return self.files
  })
}

// Remove a file from the list.
// This is called by the watcher
//
// path - String, the path of the file to update.
//
// Returns a promise that is resolved when the update
// is completed.
List.prototype.removeFile = function (path) {
  var self = this

  return Promise.try(function () {
    var pattern = self._isIncluded(path)
    var file = self._findFile(path, pattern)

    if (!pattern || !file) {
      log.debug('Removed file "%s" ignored. Does not match any file in the list.', path)
      return self.files
    }

    self.buckets.get(pattern.pattern).delete(file)

    log.info('Removed file "%s".', path)
    self._emitModified()
    return self.files
  })
}

// Inject dependencies
List.$inject = ['config.files', 'config.exclude', 'emitter', 'preprocess',
  'config.autoWatchBatchDelay']

// PUBLIC
module.exports = List
