var tokenize = require('glsl-tokenizer/string')
var fs       = require('graceful-fs')
var map      = require('map-limit')
var inherits = require('inherits')
var Emitter  = require('events/')
var findup   = require('findup')
var path     = require('path')

var glslResolve = require('glsl-resolve')
var nodeResolve = require('resolve')

var inlineName   = '__INLINE__' + Math.random()
var inlineSource = ''

module.exports = Depper

/**
 * Creates a new instance of glslify-deps. Generally, you'll
 * want to use one instance per bundle.
 *
 * @class
 * @param {String} cwd The root directory of your shader. Defaults to process.cwd()
 */
inherits(Depper, Emitter)
function Depper(opts) {
  if (!(this instanceof Depper)) return new Depper(opts)
  Emitter.call(this)

  opts = typeof opts === 'string' ? { cwd: opts } : opts
  opts = opts || {}

  this._deps       = []
  this._cwd        = opts.cwd || process.cwd()
  this._cache      = {}
  this._i          = 0
  this._transforms = []
  this._trCache    = {}
  this._fileCache  = opts.files || {}

  this._globalTransforms = []

  this._readFile = cacheWrap(opts.readFile || defaultRead, this._fileCache)
  this.resolve   = opts.resolve || glslResolve

  if (typeof this._cwd !== 'string') {
    throw new Error('glslify-deps: cwd must be a string path')
  }
}

Depper.prototype.inline = function(source, basedir, done) {
  var inlineFile = path.resolve(basedir || process.cwd(), inlineName)

  inlineSource = source

  this.add(inlineFile, function(err, tree) {
    done && done(err, !err && tree)
  })
}

/**
 * Adds a transform to use on your local dependencies.
 * Note that this should be used before calling `add`.
 *
 * Transforms are handled using a different API to browserify, e.g.:
 *
 * ``` js
 * module.exports = function transform(filename, src, opts, done) {
 *   done(null, src.toUpperCase())
 * }
 * ```
 *
 * Where `filename` is the absolute file path, `src` is the shader source
 * as a string, `opts` is an options object for configuration, and `done`
 * is a callback which takes the transformed shader source.
 *
 * @param {String|Function} transform
 * @param {Object} opts
 */
Depper.prototype.transform = function(transform, opts) {
  var name = typeof transform === 'string' ? transform : null
  var list = opts && opts.global
    ? this._globalTransforms
    : this._transforms

  // post transforms are ignored by glslify-deps, to be handled
  // by glslify after the file has been bundled.
  if (opts && opts.post) return this

  transform = this.resolveTransform(transform)
  list.push({ tr: transform, opts: opts, name: name })

  return this
}

/**
 * Adds a shader file to the graph, including its dependencies
 * which are resolved in this step. Transforms are also applied
 * in the process too, as they may potentially add or remove dependent
 * modules.
 *
 * @param {String} filename The absolute path of this file.
 * @param {String} src The shader source for this file.
 * @param {Function} done(err, deps)
 *
 * The `done` callback will be called when the entire graph has been
 * resolved, and will include an array of dependencies discovered
 * so far as its second argument.
 */
Depper.prototype.add = function(filename, done) {
  var basedir = path.dirname(filename = path.resolve(filename))
  var cache   = this._cache
  var self    = this
  var exports = []
  var imports = []

  var dep = {
      id: this._i++
    , deps: {}
    , file: filename
    , source: null
    , entry: this._i === 1
  }

  this._deps.push(dep)
  this.readFile(filename, function(err, src) {
    if (err) return done(err)

    self.getTransformsForFile(filename, function(err, trs) {
      if (err) return done(err)

      self.emit('file', filename)
      self.applyTransforms(filename, src, trs, function(err, src) {
        if (err) return done(err)

        dep.source = src
        extractPreprocessors()
        resolveImports(function(err) {
          setTimeout(function() {
            done && done(err, !err && self._deps)
          })
        })
      })
    })
  })

  return dep

  function extractPreprocessors() {
    var tokens = tokenize(dep.source)

    for (var i = 0; i < tokens.length; i++) {
      var token = tokens[i]
      if (token.type !== 'preprocessor') continue

      var data = token.data
      if (!glslifyPreprocessor(data)) continue

      var exp = glslifyExport(data)
      var imp = glslifyImport(data)
      if (exp) exports.push(exp[1])
      if (imp) imports.push(imp[2])
    }
  }

  function resolveImports(resolved) {
    map(imports, 10, function(imp, next) {
      var importName = imp.split(/\s*,\s*/).shift()

      importName = importName.trim()
      importName = importName.replace(/^'|'$/g, '')
      importName = importName.replace(/^"|"$/g, '')

      self.resolve(importName, { basedir: basedir }, function(err, resolved) {
        if (err) return next(err)

        if (cache[resolved]) {
          dep.deps[importName] = cache[resolved].id
          return next()
        }

        cache[resolved] = self.add(resolved, function(err) {
          if (err) return next(err)
          dep.deps[importName] = cache[resolved].id
          next()
        })
      })
    }, resolved)
  }
}

Depper.prototype.readFile = function(filename, done) {
  if (path.basename(filename) !== inlineName)
    return this._readFile(filename, done)

  return done(null, inlineSource)
}

/**
 * Determines which transforms to use for a particular file.
 * The rules here are the same you see in browserify:
 *
 * - your shader files will have your specified transforms applied to them
 * - shader files in node_modules do not get local transforms
 * - all files will apply transforms specified in `glslify.transform` in your
 *   `package.json` file, albeit after any transforms you specified using
 *   `depper.transform`.
 *
 * @param {String} filename The absolute path of the file in question.
 */
Depper.prototype.getTransformsForFile = function(filename, done) {
  var self  = this
  var entry = this._deps[0]

  if (!entry) return done(new Error(
    'getTransformsForFile may only be called after adding your entry file'
  ))

  var entryDir     = path.dirname(path.resolve(entry.file))
  var fileDir      = path.dirname(path.resolve(filename))
  var relative     = path.relative(entryDir, fileDir).split(path.sep)
  var node_modules = relative.indexOf('node_modules') !== -1
  var trLocal      = node_modules ? [] : this._transforms
  var trCache      = this._trCache

  if (trCache[fileDir]) {
    return done(null, trCache[fileDir])
  }

  findup(fileDir, 'package.json', function(err, found) {
    var notFound = err && err.message === 'not found'
    if (notFound) return register([])
    if (err) return done(err)

    var pkg = path.join(found, 'package.json')

    self.readFile(pkg, function(err, pkgjson) {
      if (err) return done(err)

      try {
        pkgjson = JSON.parse(pkgjson)
      } catch(e) { return done(e) }

      var transforms = (
           pkgjson['glslify']
        && pkgjson['glslify']['transform']
        || []
      )

      transforms = transforms.map(function(key) {
        var transform = Array.isArray(key)
          ? key
          : [key, {}]

        var key = transform[0]
        var opt = transform[1]

        if (opt) {
          delete opt.global
          delete opt.post
        }

        return { tr: key, opts: opt, name: key }
      }).map(function(tr) {
        tr.tr = self.resolveTransform(tr.tr)
        return tr
      })

      register(transforms)
    })
  })

  function register(transforms) {
    done(null, trCache[fileDir] = trLocal
      .concat(transforms)
      .concat(self._globalTransforms))
  }
}

/**
 * Resolves a transform.
 *
 * Functions are retained as-is.
 * Strings are resolved using node's `require` resolution algorithm,
 * and then required directly.
 *
 * @param {String|Function} transform
 */
Depper.prototype.resolveTransform = function(transform) {
  if (typeof transform === 'string') {
    transform = nodeResolve.sync(transform, {
      basedir: this._cwd
    })

    transform = require(transform)
  }

  return transform
}

/**
 * Applies a transform to a string.
 *
 * Note that transforms here are passed in differently to other methods:
 * - `tr.tr` should point to the transform function.
 * - `tr.opts` should contain the options for the transform, if applicable.
 *
 * @param {String} filename The absolute path of the file you're transforming.
 * @param {String} src The shader source you'd like to transform.
 * @param {Array} transforms The transforms you'd like to apply.
 * @param {Function} done(err, transformed)
 */
Depper.prototype.applyTransforms = function(filename, src, transforms, done) {
  var i = 0

  next(null, src)
  function next(err, updated) {
    if (err) return done(err)
    if (i >= transforms.length) return done(null, updated)

    var tr = transforms[i++]
    var opts = tr.opts

    if (!opts || typeof opts !== 'object') opts = {}
    tr.tr(filename, updated+'', tr.opts, next)
  }
}

function glslifyPreprocessor(data) {
  return /#pragma glslify:/.test(data)
}

function glslifyExport(data) {
  return /#pragma glslify:\s*export\(([^\)]+)\)/.exec(data)
}

function glslifyImport(data) {
  return /#pragma glslify:\s*([^=\s]+)\s*=\s*require\(([^\)]+)\)/.exec(data)
}

function defaultRead(src, done) {
  fs.readFile(src, 'utf8', done)
}

function cacheWrap(read, cache) {
  // resolve all cached files such that they match
  // all of the paths glslify handles, which are otherwise
  // absolute
  cache = Object.keys(cache).reduce(function(newCache, file) {
    newCache[path.resolve(file)] = cache[file]
    return newCache
  }, {})

  return function readFromCache(filename, done) {
    if (!cache[filename]) {
      return read(filename, done)
    }

    process.nextTick(function() {
      done(null, cache[filename])
    })
  }
}
