/**
 * @name Project
 * @description Provides an API for working with a project folder, which is anything that has a package.json in it.
 * @public
 *
 * The Skypager Project is an API for working with the contents, documents, and helpers
 * inside of a project folder.
 */
import 'clean-stack'
import { resolve, dirname, relative, join } from 'path'
import { EventEmitter } from 'fbemitter'
import lodash from 'lodash'
import findUp from 'find-up'
import vm from 'isomorphic-vm'
import { mixinPropertyUtils, lazy } from 'skypager-util/lib/properties'
import query from 'skypager-util/lib/query'
import * as strings from 'skypager-util/lib/string'
import Collection from './collection'
import Document from './document'
import middleware from './middleware'
import createDocumentTree from './tree'
import Cache from './cache'
import hashObject from 'object-hash'
import Module from 'module'
import Helper from './helper'

const { isString, flatten, pickBy, castArray, partialRight, uniqueId, mapKeys, omit, isError, attempt, isFunction, defaults, result, mapValues, get, chain } = lodash
const { singularize, camelCase, snakeCase } = strings

const { keys, assign, defineProperty } = Object

export const DefaultOptions = {
  target: 'node',
  paths: {
    source: 'src',
    cache: 'node_modules/.cache/skypager-project',
    output: 'dist',
    public: 'public',
    library: 'lib',
    bundles: 'dist/bundles',
    ast: 'dist/ast',
    docs: 'docs',
    data: 'data',
    logs: 'log',
    temp: 'tmp',
    main: 'skypager.js',
    manifest: 'package.json',
  }
}

export const chainSources = [
  'scopes.available',
  'constructor.chainProps',
  'options.chainProps',
]

export const querySources = [
  'scopes.available',
  'constructor.queryProps',
  'options.queryProps',
]

export class Project {
  /**
 * Create a Skypager Project wrapper around a package folder.
 *
 * @param cwd {String} an absolute path for the Project. Should be the folder which has the package.json in it.
 * @param options {Object} the options Object
 * @param options.filesystem {Boolean} use the portfolios file system adapters
 * @type {Object}
 */
  constructor (cwd, options = {}, context = {}) {
    mixinPropertyUtils(this)
    attachEmitter(this)
    const project = this

    this.realCwd = cwd
    
    this.lazy('argv', () => this.parseArgv())

    this.instanceId = result(options, 'id', () => `${this.namespace}-${ Math.floor(Date.now() / 1000)}`)

    this.hide('current', {
      collection: 'project',
      middleware: 'project'
    })

    this.hideGetter('framework', () => context.framework || context.portfolio)
    this.getter('portfolio', () => context.portfolio)

    this.hide('manifest', options.manifest || this.readJsonSync(
      this.join(get(options, 'paths.manifest', 'package.json'))
    ))

    const computedOptions = defaults(options,
      this.get('manifest.skypager'),
      DefaultOptions
    )

    this.hide('options', omit(computedOptions, 'manifest', 'portfolio', 'fs', 'fsx'))

    this.hide('middleware', middleware(this))

    require('./logger').attach(this, 
      this.getOption('logger', this.getOption('logging', {}))
    )

    this.hide('config', options.config || {})
    this.hide('configurations', {})

    this.hide('cache', new Cache())

    require('./middlewares/registries').attach(this, {
      collections: {
        type: 'Directory',
        wrapper: function(cfg, id) {
          return project.cache.fetch(`collections:${id}:${project.instanceId}:${cfg.cwd}`, () => project.mountCollection(cfg.cwd, cfg))
        },
      },

      middlewares: {
        type: 'Directory'
      },

      /**
        project.routes.register('Whatever', {
         packages: '*:projectType/:projectId/package.json',
         readmes: ':projectId/README.md',
        })

        project.routes.lookup('whatever').packages
      */
      routes: {
        type: 'Directory',
        wrapper: function(fn, id) {
          const opts = isFunction(fn) ? fn.call(project) : fn
          return project.cache.fetch(`routes:${id}:${project.cacheKey}`, () => project.createRouteMap(id, opts))
        }
      },

      selectors: {
        type: 'Context',
        context: require.context('./selectors/project', true, /\.js$/),

        wrapper: function(fn, id) {
          return (...args) =>
            project.cache.fetch(`selectors:${id}:${hashObject({args})}:${project.cacheKey}`, () => fn.call(project, project.chain, ...args).value())
        }
      }
    })

    this.register.collection('project', {
      cwd: this.realCwd,
      sync: !!options.sync,
    })

    this.register.collection('source', {
      cwd: this.paths.source,
      sync: !!options.sync,
      exclude: ['**/*.spec.*'],
    })

    this.register.collection('specs', {
      cwd: this.realCwd,
      sync: false,
      include: ['**/*.spec.*'],
    })

    this.register.collection('asts', {
      cwd: this.paths.ast,
      sync: false,
      exclude: ['**/*.spec.*'],
    })

    this.register.collection('logs', {
      cwd: this.paths.logs,
      sync: false,
    })

    this.register.collection('bundles', {
      cwd: this.paths.bundles,
      sync: false,
      type: 'script',
    })

    this.register.collection('output', {
      cwd: this.paths.output,
      sync: false,
      exclude: ['**/ast/**'],
    })

    if (options.sync) {
      const cachedCollection = this.collection.cached
      this.emit('collectionDidLoad', cachedCollection)
    }

    this.lazy('files', () => this.collection.files.map((f) => this.loadFile(f)))
    this.lazy('documents', () => this.collection.withDocuments.documents.map(d => this.loadDocument(d)))
    this.lazy('exportableDocumentsTree', () => this.createExportableDocumentsTree())
    this.lazy('cacheKey', () => this.calculateCacheKey())

    this.get('constructor.collectionDelegators', Project.collectionDelegators).forEach(meth => (
      this.hideGetter(meth, () => project.result(`collection.${meth}`))
    ))

    this.hide('configHistory', {
      project: []
    })

    this.lazy('sandbox', () => this.createSandbox())
    this.lazy('vmSandbox', () => this.vm.createContext(this.sandbox))

    this.hide('attachedProjectTypes', {})

    this.hide('context', () => this.sandbox)
  }
  
  // TODO: When we call project.cwd we should be able to hide the actual path
  get cwd() {
    return this.realCwd
  }

  get hostname() {
    try {
      return require('os').hostname()
    } catch(error) {
      return `${this.name}.skypager.local`
    }
  }
  
  get environment () {
    return {
      NODE_ENV: 'development',
      ...this.getOption('env', process.env),
    }
  }

  get isTest() {
    return this.argv.test || this.env === 'test'
  }

  get isDevelopment() {
    return !this.isProduction && !this.isTest
  }

  get isProduction() {
    return this.argv.production || this.env === 'production'
  }

  get isReact() {
    return this.hasDependency('react')
  }

  get isReactNative() {
    return this.hasDependency('react-native')
  }

  get root() {
    return this.realCwd
  }

  get parentFolder() {
    return dirname(this.realCwd)
  }

  get id() {
    return `${this.instanceId}-${this.get('manifest.version')}`
  }

  get version() {
    return this.get('options.version') || this.get('manifest.version') || '1.0.0'
  }

  get name() {
    return this.get('options.name') || this.get('manifest.name') || this.constructor.name.toLowerCase()
  }

  get paths() {
    const project = this

    return {
      cwd: this.realCwd,
      ...this.chain
        .get('options.paths', {})
        .defaults(DefaultOptions.paths)
        .mapValues((p) => (project.resolve(p)))
        .value()
    }
  }

  /**
   * A namespace can be set in the Project options, it will default
   * to the package.json scope if it exists (e.g @skypager/package) otherwise
   * the name of the Projects parent folder will be used.
   */
  get namespace() {
    const name = this.name

    return this.result('options.namespace', () => (
      name.match(/^\@\w+\//)
        ? name.split('/')[0].replace(/\^\@/,'')
        : name.split('-')[0]
      )
    )
  }

  get packageMain() {
    return this.resolve(this.get('manifest.main', 'index.js'))
  }

  get skypagerMain() {
    return this.resolve(this.get('manifest.skypager.main', 'skypager.js'))
  }

  get runtimeInfo() {
    return {
      env: this.env,
      production: this.isProduction,
      development: this.isDevelopment,
      platform: this.platform,
      hostname: this.hostname,
      ...(omit(this.argv, '_', '')),
    }
  }

  get platform() {
    if (this.isBrowser) {
      return this.isElectronRenderer ? 'electron-renderer' : 'web'
    } else if (this.isElectron) {
      return 'electron-main'
    } else {
      return 'node'
    }
  }

  get isCli() {
    return !!this.get('environment.SKYPAGER_CLI', this.get('argv._[1]', '').match(/skypager/)) 
  }

  get currentCommand() {
    return this.get('argv._[2]')
  }

  get commandPhrase() {
    return this.get('argv._').slice(2).join(" ")
  }

  get isServer() {
    return !!this.get('environment.SKYPAGER_SERVER', this.currentCommand === 'start')
  }

  get isElectron() {
    return this.get('environment.SKYPAGER_ELECTRON') || `${process.argv.join(' ')}`.match(/\/Electron/) 
  }

  get isElectronRenderer() {
    return !this.isNode && isElectronRenderer()  
  }

  get isBrowser() {
    return typeof document !== 'undefined' && typeof window !== 'undefined' && typeof document.getElementById !== 'undefined'
  }

  get isNode() {
    return !this.isBrowser && process & !process.type 
  }

  get isWindows() {
    return /^win/.test(process.platform);    
  }

  get isMacOS() {
    return /^darwin/.test(process.platform);    
  }

  get isLinux() {
    return /^linux/.test(process.platform);    
  }

  get helpers() {
    return Helper.registry
  }
 
  get projectTypeConfig() {
    return this.get('options.projectTypes', {})
  }

  get featureConfig() {
    return this.get('options.features', {})
  }

  get availableProjectTypes() {
    return this.get('projectTypes.available', [])
  }

  get availableFeatures() {
    return this.get('features.available', [])
  }

  get allHelpers() {
    return Helper.allHelpers
  }

  get availableHelpers() {
    return Helper.available
  }

  get projectTypeIds() {
    return this.chain
      .get('projectTypeConfig')
      .pick(...this.availableProjectTypes)
      .keys()
      .value()
  }

  get featureIds() {
     return this.chain
      .get('featureConfig')
      .pick(...this.availableFeatures)
      .keys()
      .value()   
  }

  get projectTypeHelpers() {
    return this.chain
      .get('projectTypeIds')
      .keyBy(v => v)
      .mapValues(v => this.projectType(v, this.get(`projectTypeConfig.${v}`, {})))
      .values()
      .value()
  }

  get featureHelpers() {
    return this.chain
      .get('featureIds')
      .keyBy(v => v)
      .mapValues(v => this.feature(v, ({
        cacheHelper: true,
        ...this.get(['featureConfig', v], {}),
      })))
      .values()
      .value()
  }

  attachProjectTypes(types = this.projectTypeIds) {
    return this.projectTypeHelpers
      .reduce((memo, projectType) => {
        projectType.attach()
        return memo  
      }, this)
  }

  enableFeatures() {
    return this.featureHelpers
      .reduce((memo, feature) => {
        feature.enable()
        return memo
      }, this)
  }
  
  hashObject(...args) {
     return hashObject(...args)
  }

  /**
   * Access one of the project options, providing a default value should one
   * not already be set.

   * @param          {String} value        an object path including something like whatever.nested.value
   * @param          {Any} defaultValue    an optional default value should the requested value not be defined
   */
  getOption(key, defaultValue) {
    key = ['options', ...flatten(castArray(key).map(k => k.split('.')))]
    return this.result(key, this.getConfig(key, defaultValue))
  }
  
  resultOption(key, defaultValue) {
    key = ['options', ...flatten(castArray(key).map(k => k.split('.')))]
    return this.result(key, this.getConfig(key, defaultValue))   
  }

  getConfig(key, defaultValue) {
    key = ['config', ...flatten(castArray(key).map(k => k.split('.')))]
    return this.result(key, defaultValue)      
  }

  get documentClass() {
    return this.getOption('documentClass', Document)
  }

  get documentSelectors() {
    return this.documentClass.selectors || Document.selectors
  }

  get documentTransforms() {
    return this
      .get('documentTypes.available', [])
      .map(this.documentType.bind(this))
      .filter(docType => docType.getTransforms )
      .reduce((memo, docType) => ({
        ...memo,
        ...docType.getTransforms()
      }), [])
  }

  get documentUtils() {
    return this
      .get('documentTypes.available', [])
      .map(this.documentType.bind(this))
      .filter(docType => docType.getTransforms )
      .reduce((memo, docType) => ({
        ...memo,
        ...docType.getTransforms()
      }), [])
  }

  get lookup() {
    const p = this

    return p.registries.available
      .reduce((memo, prop) => ({
        ...memo,
        [singularize(prop)](...args) {
          return p.registries.lookup(prop).lookup(...args)
        }
      }), {})
  }

  get register() {
    const p = this

    return p.registries.available
      .reduce((memo, prop) => ({
        ...memo,
        [singularize(prop)](...args) {
          return p.registries.lookup(prop).register(...args)
        }
      }), {})
  }

  hasDependency(dep) {
    return typeof this.select('dependencies')[dep] !== 'undefined'
  }

  document (documentId) {
    const doc = this.get(['documentsMappedById', documentId])
    return doc ? doc.ready : doc
  }

  get documentsTree() {
    return createDocumentTree(this, {
      ready: false,
    })
  }

  get loadedDocumentsTree() {
    return createDocumentTree(this, {
      ready: true,
    })
  }

  createExportableDocumentsTree() {
    return mapValues(this.loadedDocumentsTree, (v) => (
      v.createExportable ? v.exportables : v
    ))
  }

  get cachePath() {
    return this.resolve(this.paths.cache, this.cacheKey)
  }

  clearFileCache() {
    return this.fsx.removeAsync(this.cachePath)
  }

  createFileCache(location = this.cachePath) {
    return this.mkdirpAsync(location)
  }

  readJsonCache(relativePath, ...args) {
    return this.readJsonAsync(
      this.resolve(this.cachePath, relativePath), ...args
    )
  }

  readCacheFile(relativePath, ...args) {
    return this.readFileAsync(
      this.resolve(this.cachePath, relativePath), ...args
    )
  }

  cacheJson(relativePath, object) {
    return this.createFileCache()
      .then(() => this.writeJsonAsync(this.resolve(this.cachePath, relativePath), object, 'utf8'))
  }

  cacheFile(relativePath, contents) {
    return this.createFileCache()
      .then(() => this.writeFileAsync(this.resolve(this.cachePath, relativePath), contents, 'utf8'))
  }

  get chain() {
    return lodash.chain(this)
  }

  get collection() {
    return this.collections.lookup(this.current.collection)
  }

  c(collectionId = this.current.collection) {
    return this.collections.lookup(collectionId)
  }

  /**
   * Query the project documents using a parameters hash,
   * a route pattern, or a filter function.  Will return an
   * array of matching documents that are ready to work with.
   *
   * @param {String,Object,Function} params - your query parameters
   * @param {Boolean} chain - whether to keep the results wrapped in the lodash chain, defaults to true.
   */
  query(params, stayChained = true, getDoc) {
    const meth = stayChained ? this.selectChain : this.select

    getDoc = isFunction(getDoc) ? getDoc.bind(this) : (result) => result.doc.ready

    const safe = function(result) {
      try {
        return getDoc(result)
      } catch(error) {
        return undefined
      }
    }

    return this.cache.fetch(`queries:${this.cacheKey}:${hashObject({params, getDoc: getDoc.toString(), stayChained})}`, () => {
      return meth.call(this, (c) => (
        typeof params === 'string'
          ? c.invoke('route', params).map(safe).compact()
          : c.invoke('queries.documents', params).map(safe).compact()
      ))
    })
  }

  /**
   * Finds all documents matching a given route pattern.
   * Route patterns refer to express / rails style route strings
   * and are compiled using the path-to-regexp library.
   *
   * @example
   *
   *  project.route('src/helpers/:helperType/:helperCategory/:helperName.js')
   *
   * This will return all files such as "src/helpers/whatever/category/name.js"
   * along with a metadata object {helperType:"whatever", helperCategory: "category", helperName: "name"}
   *
   * These attributes can be combined with
  */
  route(pattern, stayChained = false, processResult = mirrorFn) {
    if(this.routes.available.indexOf(pattern) >= 0) {
      return this.invoke(stayChained ? 'selectChain' : 'select', (c) => c.invoke('routes.lookup', pattern))
    }

    return this.routeDocuments(pattern, stayChained, processResult)
  }

  routeDocuments(pattern, stayChained = false, processResult = mirrorFn) {
    const project = this
    const collection = this.collection
    const formatKeys = (obj) => mapKeys(obj, (v,k) => {
      return k.toString().length === 1 ? `key${k}` : k
    })

    const result = this.chain.get('collection.routers')
      .invoke('documents', pattern)
      .map(result => {
        const id = result.path[0]
        const doc = result.path[1]
        const file = doc.file
        const idParts = id.split('/')
        const meta = formatKeys(get(result, 'result', {}))

        return assign({}, meta, {
          id,
          doc,
          idParts,
          baseFolder: idParts[0],
          categoryFolder: idParts[1] || idParts[0],
          meta,
          file: assign({ path: file.path, }, project.wrapFile(file)),
        })
      })
      .map(result => processResult(result))

    return stayChained ? result : result.value()
  }

  routeFiles(pattern, chain) {
    const project = this
    const collection = this.collection

    const results = collection.routers.files(pattern).map(result => {
      const file = result.path[1]
      const id = file.relative
      const idParts = id.split('/')

      return {
        id,
        file,
        idParts,
        baseFolder: idParts[0],
        categoryFolder: idParts[1] || idParts[0],
        meta: get(result, 'result', {}),
        ...(project.wrapFile(file))
      }
    })

    return chain ? lodash.chain(results) : results
  }

  get vm() {
    return vm
  }

  get manifestModuleId() {
    return __non_webpack_require__.resolve(this.paths.manifest)
  }

  get module() {
    const result = require.cache[this.manifestModuleId]

    if (!result) {
      __non_webpack_require__(this.paths.manifest)
    }

    return require.cache[this.manifestModuleId]
  }

  /**
   * Takes a map of named routes and returns a function interface for each one
   */
  createRouteMap(name, cfg = {}, defaultOptions = {}) {
    const functions = mapValues(cfg, (options, key) => {

      if (typeof options === 'string') {
        options = {
          patterns: [options.toString()]
        }
      }

      const cacheKey = [ `routes/${name}/${key}`, options.patterns.join('|') ].join(':')

      return () => this.cache.fetch(cacheKey, this.createRouteFunction({
        ...defaultOptions,
        ...options,
      }))
    })

    const mapper = {}

    keys(functions)
      .forEach(key => defineProperty(mapper, key, {
        enumerable: true,
        configurable: false,
        get: functions[key],
      }))

    return mapper
  }

  createRouteFunction(options = {}) {
    if (typeof options === 'string') {
      options = {
        patterns: [options.toString()]
      }
    }

    let { baseRoute = '', patterns } = options

    patterns = castArray(patterns).map(pattern => `${baseRoute}${pattern}`)

    return () => (
      lodash.chain(patterns)
        .map((pattern) => this.route(pattern))
        .flatten()
        .compact()
        .uniqBy(r => r.id)
        .map((result, index) => isFunction(options.transform) ? options.transform.call(this, result, index) : result )
        .value()
    )
  }

  invoke(...args) {
    return this.chain.invoke(...args).value()
  }

  run(helperId, options = {}, callback) {
    const [type, id] = helperId.split('/')

    if (isFunction(options)) {
      callback = options
      options = {}
    }

    const helper = this.invoke(type.replace(/s$/,''), id, options)

    return Promise.resolve(helper.run())
      .catch((error) => isFunction(callback) && callback(null, error))
      .then(result => {
        isFunction(callback) && callback.call(this, result)
        return result
      })
  }

  resolvePromiseHash(object = {}) {
    return promiseHash(object)
  }

  selectAsync(selector, ...args) {
    return this.selectChain(selector, ...args)
      .thru((result) => this.resolvePromiseHash(result))
      .value()
  }

  createSandbox (locals = {}) {
    return {
      project: this,
      Skypager: this.portfolio,
      lodash,
      console,
      ...locals,
    }
  }

  loadFile (file) { return file }

  loadDocument (doc) { return doc }

  visit(route, visitor) {
    this.invoke('route', route).forEach(
      (result, _index) => result.doc.visit( partialRight(visitor, {...result, _index }, this.createSandbox()))
    )
  }

  createSandboxedScriptRunner(code, options = {}) {
    const script = this.createScript(code, omit(options, 'sandbox'))

    return () => (
      options.sync
        ? script.runInContext(options.sandbox || this.vmSandbox)
        : Promise.resolve(script.runInContext(options.sandbox || this.vmSandbox))
    )
  }

  createScriptRunner(code, options = {}, context = {}) {
    const script = this.createScript(code, options)

    return (ctx = {}) =>
      script.runInContext(options.sandbox || this.vm.createContext({
        ...this.sandbox,
        ...options,
        ...context,
        ...ctx,
      }))
  }

  createModule(code = '', options = {}) {
    const project = this
    const runCode = Module.wrap(code)

    options = {
      filename: `${this.name}-module-${uniqueId()}.js`,
      dirname: this.realCwd,
      ...options,
    }

    const id = require('path').join(options.dirname, options.filename)

    const mod = new Module(id)

    mod.paths = Module._nodeModulePaths(options.dirname)

    if (options.moduleFolders) {
      mod.paths.push(...options.moduleFolders)
    }

    mod.filename = options.filename

    return {
      code: code,
      id,
      mod,
      options,
      get ctx() {
        return options.sandbox || project.vm.createContext({
          ...project.sandbox,
          ...options.context || {},
          module: mod,
          exports: mod.exports,
          require: mod.require,
        })
      },
      get script() {
        return project.vm.createScript(Module.wrap(code), options)
      },
      get exports() {
        this.script.runInContext(this.ctx)(
          mod.exports, mod.require, mod, options.filename, options.dirname
        )

        mod.loaded = true
        return mod.exports
      }
    }
  }

  createScript(code = '', options = {}) {
    return new this.vm.Script(code.toString(), options)
  }

  get babelRc() {
    const path = this.findUp('.babelrc', {cwd: this.join('whatever')})
    return path && this.readJsonSync(path)
  }

  createAsyncScriptRunner(code = '', options = {}, context = {}) {
    return (ctx = {}) => (
      Promise.resolve()
        .then(() => this.createScriptRunner(code, options, context)(ctx))
    )
  }

  wrapFile (file) {
    const project = this
    const { path } = file

    return {
      contents() {
        return project.readFileSync(path).toString()
      },

      getFile() {
        return file
      },

      json() {
        return project.readJsonSync(path)
      },

      read() {
        return project.readFileAsync(path).then(file => file.toString())
      },

      readJson() {
        return project.readJsonAsync(path)
      },
    }
  }
  /**
   * Get information about this project's parent. It will look up the path for the closest package.json
   * @return {Object} An object which provides access to the package.json path, folder path, and contents
   */
  get parentModule() {
    const proj = this

    return {
      get path() {
        return proj.findUp('package.json', {
          cwd: proj.parentFolder,
        })
      },

      get folder() {
        return dirname(proj.parentModule.path)
      },

      get manifest() {
        try {
          return proj.readJsonSync(
            proj.parentModule.path
          )
        } catch(e) {
          return proj.manifest
        }
      },

      get projectLoader() {
        return (options = {}) =>
          proj.portfolio.load(proj.parentModule.folder, options)
      },

      get project() {
        return proj.parentModule.projectLoader({
          sync: false,
          fresh: true,
        })
      }
    }
  }

  parseArgv() {
    const base = defaults({}, this.getOption('argv'), require('minimist')(process.argv))
    const transformedKeys = mapKeys(omit(base, '_'), (v,k) => camelCase(snakeCase(k)))
    return assign(base, transformedKeys)
  }

  get env() {
    const env = this.environment
    return env.SKYPAGER_ENV ? env.SKYPAGER_ENV : env.NODE_ENV
  }


  calculateCacheKey() {
    try {
      return this.unsafeCalculateCacheKey()
    } catch(error) {
      return this.id
    }
  }

  // this should be the same every time the project fires up, unless any files have changed
  unsafeCalculateCacheKey() {
    const wrappers = this.chain.get('collection.importer.fileWrappersSync')

    this.maxUpdatedAt = wrappers.map(f => (
      f.stat && f.stat.mtime && f.stat.mtime.getTime()
    )).max().value()

    this.filesCount = wrappers.size().value()

    const { branch } = this.gitInfo

    return [
      this.name,
      this.version,
      branch,
      this.maxUpdatedAt,
      this.filesCount,
    ].join(':')
  }

  get sourceFilePaths() {
    const sourcePaths = this.validSourcePaths
    return this.files
      .filter(f => sourcePaths.indexOf(f.dirname) >= 0).map(f => f.path)
      .filter(f => !f.match(/(spec|test).*\.\w+$/))
  }

  get validSourcePaths() {
      return this.sourcePaths.filter(p => this.fs.existsSync(p))
  }

  get sourcePaths() {
    return this
      .chain.get('options.sourcePaths', [])
      .concat([this.paths.source])
      .uniq()
      .value()
  }

  get chains() {
    const project = this

    const chains = this.chain
      .at(...chainSources)
      .flatten()
      .compact()
      .uniq()
      .reduce((memo, prop) => {
        lazy(memo, prop, ()=> chain(project.get(prop)))
        return memo
      },{})

    return chains.value()
  }

  get queries() {
    const project = this

    const queries = this.chain
      .at(...querySources)
      .flatten()
      .compact()
      .uniq()
      .reduce((memo, prop) => {
        memo[prop] = (...args) => query(project.get(prop), ...args)
        return memo
      },{})

    return queries.value()
  }

  get gitignores() {
    return this.getOption('ignorePaths', ['tmp/', 'log/', 'dist/', '.DS_Store'])
      .concat(this.gitignoreContents)
      .map(p => `!${p}`)
  }

  get gitignoreContents() {
    return this.attemptReadSync('.gitignore', '').split("\n")
    .filter(p => p && p.length > 2 && !p.trim().startsWith('#'))
  }

  get skypagerIgnoreContents() {
    return this.attemptReadSync('.skypagerignore', '').split("\n")
    .filter(p => p && p.length > 2 && !p.trim().startsWith('#'))
  }

  /**
    PATH HELPERS

    Helpers to make working with project paths easier
  */
  join(...args) { return join(this.realCwd, ...args) }

  relative(...args) { return relative(this.realCwd, ...args) }

  resolve(...args) {
    return resolve(this.realCwd, ...args)
  }

  attempt(...args) {
    return attempt(...args)  
  }

  attemptReadSync(path, fallbackValue = '') {
    const result = attempt(
      () => this.fs.readFileSync(this.join(path)).toString()
    )

    return isError(result)
      ? fallbackValue
      : result
  }

  use (middleware) {
    if (isFunction(middleware.preRegister)) {
      middleware.preRegister.call(this, this)
    }

    if (isString(middleware) && this.availableProjectTypes.indexOf(middleware) !== -1) {
      project.projectType(middleware).use()
    } else if (isFunction(middleware) && middleware.providesProjectType) {
      this.middleware.use(middleware.bind(this))
    } else if (isFunction(middleware)) {
      this.middleware.use(middleware.bind(this))
    }

    return this
  }

  get autostart() {
    return this.startAsync().then(() => {
      return this
    })
  }

  start (options = {}, callback) {
    const project = this

    if (this.started) {
      if (isFunction(callback)) {
        callback.call(project, null, project)
        return this
      } else {
        return Promise.resolve(this)
      }
    }

    if (isFunction(callback)) {
      return this.middleware.run(options, this.sandbox, (...args) => {
        if(!args[0]) { this.started = true }
        callback.call(project, ...args)
      })
    } else {
      return this.startAsync(options, this.sandbox)
        .then((...args) => callback.call(project, ...args) )
    }
  }

  startAsync (options = {}) {
    if (this.started) {
      return Promise.resolve(this)
    }

    return new Promise((resolve,reject) => {
      try {
        this.middleware.run(options, this.sandbox, (err, ...callbackArgs) => {
          if (err) { reject(err) } else { resolve(this) }
        })
      } catch(error) {
        throw(error)
      }

    })
    .then(() => {
      this.started = true
      return this
    })
    .catch(error => {
      console.log('Error in start async method', error)
    })
  }

  mixin (methods = {}, target = this) {
    methods = pickBy(methods, (v) => typeof v === 'function')
    return assign(target, mapValues(methods, (fn) => partialRight(fn.bind(target), target.sandbox || target.context)))
  }

  mountCollection (cwd = this.realCwd, options = {}) {
    const project = this

    options = {
      importerOptions: project.get('options.importer', project.get('options.importerOptions', {})),
      project: project,
      documentClass: Document,
      ...options,
      exclude: (options.exclude === false || options.includeAll
        ? [] : [
          ...options.exclude || [],
          ...options.ignore || [],
          ...(project.gitignores || []).filter(f => !f.startsWith('!')),
          ...project.skypagerIgnoreContents || [],
          '**/log/**',
          '**/bin/**',
        ]),
    }

    return Collection.mount(cwd, {
      ...options,
      collection: this,
    })
  }


  findUp(fileName, options = {}) {
    return findUp.sync(fileName, {
      cwd: this.parentFolder,
      ...options,
    })
  }

  select(selector, ...args) {
    if (isFunction(selector)) {
      const result = selector.call(this, this.chain, ...args)
      return result && typeof result.value === 'function' ? result.value() : result
    } else if (typeof selector === 'string') {
      return this.selectors.lookup(selector)(...args)
    }
  }

  selectChain(selector, ...args) {
    return this.chain.invoke('select', selector, ...args)
  }

  selectorMap (config = {}, resolveChain = true) {
    const c = this.chain
      .plant(config)
      .mapValues((selector) => this.select(...castArray(selector)))

    return resolveChain ? c.value() : c
  }

  createEntityFrom(...properties) {
    const src = this.slice(...properties)
    return require('./entity').createEntity(src)
  }

  /**
   * Create a lodash chain by picking some properties from the project.
   * @param  {Array[String]} properties List of properties you want to be a part of the chain
   * @return {Chain}           The lodash chain whose initial value consists of the properties given
   */
  slice (...properties) {
    return properties.reduce((memo, prop) => {
      lodash.set(memo, prop, this.result(prop))
      return memo
    }, {})
  }

  /**
   * Create a lodash chain with the given object paths.
   */
  at (...paths) {
    return this.chain.at(...paths)
  }

  getEither(attempts = [], defaultValue) {
    const match = attempts.find(property => this.has(property))
    return match ? this.get(match, defaultValue) : defaultValue
  }

  resultEither(attempts = [], defaultValue) {
    const match = attempts.find(property => this.has(property))
    return match ? this.result(match, defaultValue) : defaultValue
  }

  static collectionDelegators = [
    'childFolderNames',
    'childFolderPaths',
    'subFolderNames',
    'subFolderPaths',
    'rootFiles',
    'groupedByChildFolder',
    'groupedByChildFolderAndExtension',
    'relativeFilePaths',
    'relativeSubFolderPaths',
    'getFileByRelativePath',
    'filesIndex',
    'relativeObjectPaths',
    'subFolderObjectPaths',
    'routers',
    'documentsMap',
    'documentsMappedById',
    'documentEntryPoints',
    'depths',
    'gitInfo',
  ]

  static chainProps = [
    'files',
    'documents',
    'childFolderPaths',
    'childFolderNames',
    'subFolderNames',
    'subFolderPaths',
  ]

  static queryProps = [
    'files',
    'documents',
    'childFolderPaths',
    'childFolderNames',
    'subFolderNames',
    'subFolderPaths',
  ]

  static get Collection() {
    return Collection
  }

  static get Document() {
    return Document
  }

  static load (cwd, options = {}, context = {}) {
    return new this(cwd, options, context)
  }

  static loadSync(cwd, options = {}, context = {}) {
    return this.load(cwd, {
      ...options,
      sync: true
    }, context)
  }
}

export default Project

export function load(cwd = process.cwd(), options = {}, context = {}) {
  return new Project(cwd, options, context)
}

export function attachEmitter(project) {
    project.hide('emitter', new EventEmitter())
    project.hide('on', project.emitter.addListener.bind(project.emitter))
    project.hide('once', project.emitter.once.bind(project.emitter))
    project.hide('off', project.emitter.removeCurrentListener.bind(project.emitter))
    project.hide('emit', project.emitter.emit.bind(project.emitter))
    project.hide('trigger', project.emitter.emit.bind(project.emitter))
}

export function attachRegistry (project, name, options = {}) {
  if (project.registries.available.indexOf(name) >= 0) {
    return project.registries.lookup(name)
  }

  project.registries.register(name, () => create({
    name,
    type: 'Directory',
    ...options,
  }))

  if (options.prop !== false && typeof project[name] === 'undefined') {
    project.getter(name, () => project.registries.lookup(name))
  }

  if (options.prop !== false && typeof project[singularize(name)] === 'undefined') {
    project.getter(singularize(name), (...args) => project[name].lookup(...args))
  }

  return project
}

function mirrorFn(obj) {
  return obj
}

function isElectronRenderer () {
  // node-integration is disabled
  if (!process) return true

  // We're in node.js somehow
  if (!process.type) return false

  return process.type === 'renderer'
}

export function promiseHash(obj) {
  var awaitables = []

  var keys = Object.keys(obj)

  for (var i = 0; i < keys.length; i++) {
    var key = keys[i]
    var a$ = obj[key]
    awaitables.push(a$)
  }

  return Promise.all(awaitables).then(function (results = {}) {
    var byName = {}
    for (var i = 0; i < keys.length; i++) {
      var key = keys[i]
      byName[key] = results[i]
    }
    return byName
  })
}
