const path = require('path')

const _ = require('underscore-plus')
const fs = require('fs-plus')
const {Emitter, Disposable, CompositeDisposable} = require('event-kit')
const TextBuffer = require('text-buffer')
const {watchPath} = require('./path-watcher')

const DefaultDirectoryProvider = require('./default-directory-provider')
const Model = require('./model')
const GitRepositoryProvider = require('./git-repository-provider')

// Extended: Represents a project that's opened in Atom.
//
// An instance of this class is always available as the `atom.project` global.
module.exports =
class Project extends Model {
  /*
  Section: Construction and Destruction
  */

  constructor ({notificationManager, packageManager, config, applicationDelegate, grammarRegistry}) {
    super()
    this.notificationManager = notificationManager
    this.applicationDelegate = applicationDelegate
    this.grammarRegistry = grammarRegistry

    this.emitter = new Emitter()
    this.buffers = []
    this.rootDirectories = []
    this.repositories = []
    this.directoryProviders = []
    this.defaultDirectoryProvider = new DefaultDirectoryProvider()
    this.repositoryPromisesByPath = new Map()
    this.repositoryProviders = [new GitRepositoryProvider(this, config)]
    this.loadPromisesByPath = {}
    this.watcherPromisesByPath = {}
    this.retiredBufferIDs = new Set()
    this.retiredBufferPaths = new Set()
    this.subscriptions = new CompositeDisposable()
    this.consumeServices(packageManager)
  }

  destroyed () {
    for (let buffer of this.buffers.slice()) { buffer.destroy() }
    for (let repository of this.repositories.slice()) {
      if (repository != null) repository.destroy()
    }
    for (let path in this.watcherPromisesByPath) {
      this.watcherPromisesByPath[path].then(watcher => { watcher.dispose() })
    }
    this.rootDirectories = []
    this.repositories = []
  }

  reset (packageManager) {
    this.emitter.dispose()
    this.emitter = new Emitter()

    this.subscriptions.dispose()
    this.subscriptions = new CompositeDisposable()

    for (let buffer of this.buffers) {
      if (buffer != null) buffer.destroy()
    }
    this.buffers = []
    this.setPaths([])
    this.loadPromisesByPath = {}
    this.retiredBufferIDs = new Set()
    this.retiredBufferPaths = new Set()
    this.consumeServices(packageManager)
  }

  destroyUnretainedBuffers () {
    for (let buffer of this.getBuffers()) {
      if (!buffer.isRetained()) buffer.destroy()
    }
  }

  /*
  Section: Serialization
  */

  deserialize (state) {
    this.retiredBufferIDs = new Set()
    this.retiredBufferPaths = new Set()

    const handleBufferState = (bufferState) => {
      if (bufferState.shouldDestroyOnFileDelete == null) {
        bufferState.shouldDestroyOnFileDelete = () => atom.config.get('core.closeDeletedFileTabs')
      }

      // Use a little guilty knowledge of the way TextBuffers are serialized.
      // This allows TextBuffers that have never been saved (but have filePaths) to be deserialized, but prevents
      // TextBuffers backed by files that have been deleted from being saved.
      bufferState.mustExist = bufferState.digestWhenLastPersisted !== false

      return TextBuffer.deserialize(bufferState).catch((_) => {
        this.retiredBufferIDs.add(bufferState.id)
        this.retiredBufferPaths.add(bufferState.filePath)
        return null
      })
    }

    const bufferPromises = []
    for (let bufferState of state.buffers) {
      bufferPromises.push(handleBufferState(bufferState))
    }

    return Promise.all(bufferPromises).then(buffers => {
      this.buffers = buffers.filter(Boolean)
      for (let buffer of this.buffers) {
        this.grammarRegistry.maintainLanguageMode(buffer)
        this.subscribeToBuffer(buffer)
      }
      this.setPaths(state.paths || [], {mustExist: true, exact: true})
    })
  }

  serialize (options = {}) {
    return {
      deserializer: 'Project',
      paths: this.getPaths(),
      buffers: _.compact(this.buffers.map(function (buffer) {
        if (buffer.isRetained()) {
          const isUnloading = options.isUnloading === true
          return buffer.serialize({markerLayers: isUnloading, history: isUnloading})
        }
      }))
    }
  }

  /*
  Section: Event Subscription
  */

  // Public: Invoke the given callback when the project paths change.
  //
  // * `callback` {Function} to be called after the project paths change.
  //    * `projectPaths` An {Array} of {String} project paths.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangePaths (callback) {
    return this.emitter.on('did-change-paths', callback)
  }

  // Public: Invoke the given callback when a text buffer is added to the
  // project.
  //
  // * `callback` {Function} to be called when a text buffer is added.
  //   * `buffer` A {TextBuffer} item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidAddBuffer (callback) {
    return this.emitter.on('did-add-buffer', callback)
  }

  // Public: Invoke the given callback with all current and future text
  // buffers in the project.
  //
  // * `callback` {Function} to be called with current and future text buffers.
  //   * `buffer` A {TextBuffer} item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeBuffers (callback) {
    for (let buffer of this.getBuffers()) { callback(buffer) }
    return this.onDidAddBuffer(callback)
  }

  // Extended: Invoke a callback when a filesystem change occurs within any open
  // project path.
  //
  // ```js
  // const disposable = atom.project.onDidChangeFiles(events => {
  //   for (const event of events) {
  //     // "created", "modified", "deleted", or "renamed"
  //     console.log(`Event action: ${event.type}`)
  //
  //     // absolute path to the filesystem entry that was touched
  //     console.log(`Event path: ${event.path}`)
  //
  //     if (event.type === 'renamed') {
  //       console.log(`.. renamed from: ${event.oldPath}`)
  //     }
  //   }
  // }
  //
  // disposable.dispose()
  // ```
  //
  // To watch paths outside of open projects, use the `watchPaths` function instead; see {PathWatcher}.
  //
  // When writing tests against functionality that uses this method, be sure to wait for the
  // {Promise} returned by {getWatcherPromise()} before manipulating the filesystem to ensure that
  // the watcher is receiving events.
  //
  // * `callback` {Function} to be called with batches of filesystem events reported by
  //   the operating system.
  //    * `events` An {Array} of objects that describe a batch of filesystem events.
  //     * `action` {String} describing the filesystem action that occurred. One of `"created"`,
  //       `"modified"`, `"deleted"`, or `"renamed"`.
  //     * `path` {String} containing the absolute path to the filesystem entry
  //       that was acted upon.
  //     * `oldPath` For rename events, {String} containing the filesystem entry's
  //       former absolute path.
  //
  // Returns a {Disposable} to manage this event subscription.
  onDidChangeFiles (callback) {
    return this.emitter.on('did-change-files', callback)
  }

  /*
  Section: Accessing the git repository
  */

  // Public: Get an {Array} of {GitRepository}s associated with the project's
  // directories.
  //
  // This method will be removed in 2.0 because it does synchronous I/O.
  // Prefer the following, which evaluates to a {Promise} that resolves to an
  // {Array} of {GitRepository} objects:
  // ```
  // Promise.all(atom.project.getDirectories().map(
  //     atom.project.repositoryForDirectory.bind(atom.project)))
  // ```
  getRepositories () {
    return this.repositories
  }

  // Public: Get the repository for a given directory asynchronously.
  //
  // * `directory` {Directory} for which to get a {GitRepository}.
  //
  // Returns a {Promise} that resolves with either:
  // * {GitRepository} if a repository can be created for the given directory
  // * `null` if no repository can be created for the given directory.
  repositoryForDirectory (directory) {
    const pathForDirectory = directory.getRealPathSync()
    let promise = this.repositoryPromisesByPath.get(pathForDirectory)
    if (!promise) {
      const promises = this.repositoryProviders.map((provider) =>
        provider.repositoryForDirectory(directory)
      )
      promise = Promise.all(promises).then((repositories) => {
        const repo = repositories.find((repo) => repo != null) || null

        // If no repository is found, remove the entry for the directory in
        // @repositoryPromisesByPath in case some other RepositoryProvider is
        // registered in the future that could supply a Repository for the
        // directory.
        if (repo == null) this.repositoryPromisesByPath.delete(pathForDirectory)

        if (repo && repo.onDidDestroy) {
          repo.onDidDestroy(() => this.repositoryPromisesByPath.delete(pathForDirectory))
        }

        return repo
      })
      this.repositoryPromisesByPath.set(pathForDirectory, promise)
    }
    return promise
  }

  /*
  Section: Managing Paths
  */

  // Public: Get an {Array} of {String}s containing the paths of the project's
  // directories.
  getPaths () {
    return this.rootDirectories.map((rootDirectory) => rootDirectory.getPath())
  }

  // Public: Set the paths of the project's directories.
  //
  // * `projectPaths` {Array} of {String} paths.
  // * `options` An optional {Object} that may contain the following keys:
  //   * `mustExist` If `true`, throw an Error if any `projectPaths` do not exist. Any remaining `projectPaths` that
  //     do exist will still be added to the project. Default: `false`.
  //   * `exact` If `true`, only add a `projectPath` if it names an existing directory. If `false` and any `projectPath`
  //     is a file or does not exist, its parent directory will be added instead. Default: `false`.
  setPaths (projectPaths, options = {}) {
    for (let repository of this.repositories) {
      if (repository != null) repository.destroy()
    }
    this.rootDirectories = []
    this.repositories = []

    for (let path in this.watcherPromisesByPath) {
      this.watcherPromisesByPath[path].then(watcher => { watcher.dispose() })
    }
    this.watcherPromisesByPath = {}

    const missingProjectPaths = []
    for (let projectPath of projectPaths) {
      try {
        this.addPath(projectPath, {emitEvent: false, mustExist: true, exact: options.exact === true})
      } catch (e) {
        if (e.missingProjectPaths != null) {
          missingProjectPaths.push(...e.missingProjectPaths)
        } else {
          throw e
        }
      }
    }

    this.emitter.emit('did-change-paths', projectPaths)

    if ((options.mustExist === true) && (missingProjectPaths.length > 0)) {
      const err = new Error('One or more project directories do not exist')
      err.missingProjectPaths = missingProjectPaths
      throw err
    }
  }

  // Public: Add a path to the project's list of root paths
  //
  // * `projectPath` {String} The path to the directory to add.
  // * `options` An optional {Object} that may contain the following keys:
  //   * `mustExist` If `true`, throw an Error if the `projectPath` does not exist. If `false`, a `projectPath` that does
  //     not exist is ignored. Default: `false`.
  //   * `exact` If `true`, only add `projectPath` if it names an existing directory. If `false`, if `projectPath` is a
  //     a file or does not exist, its parent directory will be added instead.
  addPath (projectPath, options = {}) {
    const directory = this.getDirectoryForProjectPath(projectPath)

    let ok = true
    if (options.exact === true) {
      ok = (directory.getPath() === projectPath)
    }
    ok = ok && directory.existsSync()

    if (!ok) {
      if (options.mustExist === true) {
        const err = new Error(`Project directory ${directory} does not exist`)
        err.missingProjectPaths = [projectPath]
        throw err
      } else {
        return
      }
    }

    for (let existingDirectory of this.getDirectories()) {
      if (existingDirectory.getPath() === directory.getPath()) { return }
    }

    this.rootDirectories.push(directory)

    const didChangeCallback = events => {
      // Stop event delivery immediately on removal of a rootDirectory, even if its watcher
      // promise has yet to resolve at the time of removal
      if (this.rootDirectories.includes(directory)) {
        this.emitter.emit('did-change-files', events)
      }
    }
    // We'll use the directory's custom onDidChangeFiles callback, if available.
    // CustomDirectory::onDidChangeFiles should match the signature of
    // Project::onDidChangeFiles below (although it may resolve asynchronously)
    this.watcherPromisesByPath[directory.getPath()] =
      directory.onDidChangeFiles != null
        ? Promise.resolve(directory.onDidChangeFiles(didChangeCallback))
        : watchPath(directory.getPath(), {}, didChangeCallback)

    for (let watchedPath in this.watcherPromisesByPath) {
      if (!this.rootDirectories.find(dir => dir.getPath() === watchedPath)) {
        this.watcherPromisesByPath[watchedPath].then(watcher => { watcher.dispose() })
      }
    }

    let repo = null
    for (let provider of this.repositoryProviders) {
      if (provider.repositoryForDirectorySync) {
        repo = provider.repositoryForDirectorySync(directory)
      }
      if (repo) { break }
    }
    this.repositories.push(repo != null ? repo : null)

    if (options.emitEvent !== false) {
      this.emitter.emit('did-change-paths', this.getPaths())
    }
  }

  getDirectoryForProjectPath (projectPath) {
    let directory = null
    for (let provider of this.directoryProviders) {
      if (typeof provider.directoryForURISync === 'function') {
        directory = provider.directoryForURISync(projectPath)
        if (directory) break
      }
    }
    if (directory == null) {
      directory = this.defaultDirectoryProvider.directoryForURISync(projectPath)
    }
    return directory
  }

  // Extended: Access a {Promise} that resolves when the filesystem watcher associated with a project
  // root directory is ready to begin receiving events.
  //
  // This is especially useful in test cases, where it's important to know that the watcher is
  // ready before manipulating the filesystem to produce events.
  //
  // * `projectPath` {String} One of the project's root directories.
  //
  // Returns a {Promise} that resolves with the {PathWatcher} associated with this project root
  // once it has initialized and is ready to start sending events. The Promise will reject with
  // an error instead if `projectPath` is not currently a root directory.
  getWatcherPromise (projectPath) {
    return this.watcherPromisesByPath[projectPath] ||
      Promise.reject(new Error(`${projectPath} is not a project root`))
  }

  // Public: remove a path from the project's list of root paths.
  //
  // * `projectPath` {String} The path to remove.
  removePath (projectPath) {
    // The projectPath may be a URI, in which case it should not be normalized.
    if (!this.getPaths().includes(projectPath)) {
      projectPath = this.defaultDirectoryProvider.normalizePath(projectPath)
    }

    let indexToRemove = null
    for (let i = 0; i < this.rootDirectories.length; i++) {
      const directory = this.rootDirectories[i]
      if (directory.getPath() === projectPath) {
        indexToRemove = i
        break
      }
    }

    if (indexToRemove != null) {
      this.rootDirectories.splice(indexToRemove, 1)
      const [removedRepository] = this.repositories.splice(indexToRemove, 1)
      if (!this.repositories.includes(removedRepository)) {
        if (removedRepository) removedRepository.destroy()
      }
      if (this.watcherPromisesByPath[projectPath] != null) {
        this.watcherPromisesByPath[projectPath].then(w => w.dispose())
      }
      delete this.watcherPromisesByPath[projectPath]
      this.emitter.emit('did-change-paths', this.getPaths())
      return true
    } else {
      return false
    }
  }

  // Public: Get an {Array} of {Directory}s associated with this project.
  getDirectories () {
    return this.rootDirectories
  }

  resolvePath (uri) {
    if (!uri) { return }

    if (uri.match(/[A-Za-z0-9+-.]+:\/\//)) { // leave path alone if it has a scheme
      return uri
    } else {
      let projectPath
      if (fs.isAbsolute(uri)) {
        return this.defaultDirectoryProvider.normalizePath(fs.resolveHome(uri))
      // TODO: what should we do here when there are multiple directories?
      } else if ((projectPath = this.getPaths()[0])) {
        return this.defaultDirectoryProvider.normalizePath(fs.resolveHome(path.join(projectPath, uri)))
      } else {
        return undefined
      }
    }
  }

  relativize (fullPath) {
    return this.relativizePath(fullPath)[1]
  }

  // Public: Get the path to the project directory that contains the given path,
  // and the relative path from that project directory to the given path.
  //
  // * `fullPath` {String} An absolute path.
  //
  // Returns an {Array} with two elements:
  // * `projectPath` The {String} path to the project directory that contains the
  //   given path, or `null` if none is found.
  // * `relativePath` {String} The relative path from the project directory to
  //   the given path.
  relativizePath (fullPath) {
    let result = [null, fullPath]
    if (fullPath != null) {
      for (let rootDirectory of this.rootDirectories) {
        const relativePath = rootDirectory.relativize(fullPath)
        if ((relativePath != null) && (relativePath.length < result[1].length)) {
          result = [rootDirectory.getPath(), relativePath]
        }
      }
    }
    return result
  }

  // Public: Determines whether the given path (real or symbolic) is inside the
  // project's directory.
  //
  // This method does not actually check if the path exists, it just checks their
  // locations relative to each other.
  //
  // ## Examples
  //
  // Basic operation
  //
  // ```coffee
  // # Project's root directory is /foo/bar
  // project.contains('/foo/bar/baz')        # => true
  // project.contains('/usr/lib/baz')        # => false
  // ```
  //
  // Existence of the path is not required
  //
  // ```coffee
  // # Project's root directory is /foo/bar
  // fs.existsSync('/foo/bar/baz')           # => false
  // project.contains('/foo/bar/baz')        # => true
  // ```
  //
  // * `pathToCheck` {String} path
  //
  // Returns whether the path is inside the project's root directory.
  contains (pathToCheck) {
    return this.rootDirectories.some(dir => dir.contains(pathToCheck))
  }

  /*
  Section: Private
  */

  consumeServices ({serviceHub}) {
    serviceHub.consume(
      'atom.directory-provider',
      '^0.1.0',
      provider => {
        this.directoryProviders.unshift(provider)
        return new Disposable(() => {
          return this.directoryProviders.splice(this.directoryProviders.indexOf(provider), 1)
        })
      })

    return serviceHub.consume(
      'atom.repository-provider',
      '^0.1.0',
      provider => {
        this.repositoryProviders.unshift(provider)
        if (this.repositories.includes(null)) { this.setPaths(this.getPaths()) }
        return new Disposable(() => {
          return this.repositoryProviders.splice(this.repositoryProviders.indexOf(provider), 1)
        })
      })
  }

  // Retrieves all the {TextBuffer}s in the project; that is, the
  // buffers for all open files.
  //
  // Returns an {Array} of {TextBuffer}s.
  getBuffers () {
    return this.buffers.slice()
  }

  // Is the buffer for the given path modified?
  isPathModified (filePath) {
    const bufferForPath = this.findBufferForPath(this.resolvePath(filePath))
    return bufferForPath && bufferForPath.isModified()
  }

  findBufferForPath (filePath) {
    return _.find(this.buffers, buffer => buffer.getPath() === filePath)
  }

  findBufferForId (id) {
    return _.find(this.buffers, buffer => buffer.getId() === id)
  }

  // Only to be used in specs
  bufferForPathSync (filePath) {
    const absoluteFilePath = this.resolvePath(filePath)
    if (this.retiredBufferPaths.has(absoluteFilePath)) { return null }

    let existingBuffer
    if (filePath) { existingBuffer = this.findBufferForPath(absoluteFilePath) }
    return existingBuffer != null ? existingBuffer : this.buildBufferSync(absoluteFilePath)
  }

  // Only to be used when deserializing
  bufferForIdSync (id) {
    if (this.retiredBufferIDs.has(id)) { return null }

    let existingBuffer
    if (id) { existingBuffer = this.findBufferForId(id) }
    return existingBuffer != null ? existingBuffer : this.buildBufferSync()
  }

  // Given a file path, this retrieves or creates a new {TextBuffer}.
  //
  // If the `filePath` already has a `buffer`, that value is used instead. Otherwise,
  // `text` is used as the contents of the new buffer.
  //
  // * `filePath` A {String} representing a path. If `null`, an "Untitled" buffer is created.
  //
  // Returns a {Promise} that resolves to the {TextBuffer}.
  bufferForPath (absoluteFilePath) {
    let existingBuffer
    if (absoluteFilePath != null) { existingBuffer = this.findBufferForPath(absoluteFilePath) }
    if (existingBuffer) {
      return Promise.resolve(existingBuffer)
    } else {
      return this.buildBuffer(absoluteFilePath)
    }
  }

  shouldDestroyBufferOnFileDelete () {
    return atom.config.get('core.closeDeletedFileTabs')
  }

  // Still needed when deserializing a tokenized buffer
  buildBufferSync (absoluteFilePath) {
    const params = {shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete}

    let buffer
    if (absoluteFilePath != null) {
      buffer = TextBuffer.loadSync(absoluteFilePath, params)
    } else {
      buffer = new TextBuffer(params)
    }
    this.addBuffer(buffer)
    return buffer
  }

  // Given a file path, this sets its {TextBuffer}.
  //
  // * `absoluteFilePath` A {String} representing a path.
  // * `text` The {String} text to use as a buffer.
  //
  // Returns a {Promise} that resolves to the {TextBuffer}.
  buildBuffer (absoluteFilePath) {
    const params = {shouldDestroyOnFileDelete: this.shouldDestroyBufferOnFileDelete}

    let promise
    if (absoluteFilePath != null) {
      if (this.loadPromisesByPath[absoluteFilePath] == null) {
        this.loadPromisesByPath[absoluteFilePath] =
          TextBuffer.load(absoluteFilePath, params).catch(error => {
            delete this.loadPromisesByPath[absoluteFilePath]
            throw error
          })
      }
      promise = this.loadPromisesByPath[absoluteFilePath]
    } else {
      promise = Promise.resolve(new TextBuffer(params))
    }
    return promise.then(buffer => {
      delete this.loadPromisesByPath[absoluteFilePath]
      this.addBuffer(buffer)
      return buffer
    })
  }

  addBuffer (buffer, options = {}) {
    this.buffers.push(buffer)
    this.subscriptions.add(this.grammarRegistry.maintainLanguageMode(buffer))
    this.subscribeToBuffer(buffer)
    this.emitter.emit('did-add-buffer', buffer)
    return buffer
  }

  // Removes a {TextBuffer} association from the project.
  //
  // Returns the removed {TextBuffer}.
  removeBuffer (buffer) {
    const index = this.buffers.indexOf(buffer)
    if (index !== -1) { return this.removeBufferAtIndex(index) }
  }

  removeBufferAtIndex (index, options = {}) {
    const [buffer] = this.buffers.splice(index, 1)
    return (buffer != null ? buffer.destroy() : undefined)
  }

  eachBuffer (...args) {
    let subscriber
    if (args.length > 1) { subscriber = args.shift() }
    const callback = args.shift()

    for (let buffer of this.getBuffers()) { callback(buffer) }
    if (subscriber) {
      return subscriber.subscribe(this, 'buffer-created', buffer => callback(buffer))
    } else {
      return this.on('buffer-created', buffer => callback(buffer))
    }
  }

  subscribeToBuffer (buffer) {
    buffer.onWillSave(async ({path}) => this.applicationDelegate.emitWillSavePath(path))
    buffer.onDidSave(({path}) => this.applicationDelegate.emitDidSavePath(path))
    buffer.onDidDestroy(() => this.removeBuffer(buffer))
    buffer.onDidChangePath(() => {
      if (!(this.getPaths().length > 0)) {
        this.setPaths([path.dirname(buffer.getPath())])
      }
    })
    buffer.onWillThrowWatchError(({error, handle}) => {
      handle()
      const message =
        `Unable to read file after file \`${error.eventType}\` event.` +
        `Make sure you have permission to access \`${buffer.getPath()}\`.`
      this.notificationManager.addWarning(message, {
        detail: error.message,
        dismissable: true
      })
    })
  }
}
