_ = require 'underscore-plus'
fs = require 'fs-plus'
{Emitter} = require 'emissary'
CSON = require 'season'
path = require 'path'
async = require 'async'
pathWatcher = require 'pathwatcher'

# Public: Used to access all of Atom's configuration details.
#
# An instance of this class is always available as the `atom.config` global.
#
# ## Best practices
#
# * Create your own root keypath using your package's name.
# * Don't depend on (or write to) configuration keys outside of your keypath.
#
# ## Example
#
# ```coffeescript
# atom.config.set('myplugin.key', 'value')
# atom.config.observe 'myplugin.key', ->
#   console.log 'My configuration changed:', atom.config.get('myplugin.key')
# ```
module.exports =
class Config
  Emitter.includeInto(this)

  # Created during initialization, available as `atom.config`
  constructor: ({@configDirPath, @resourcePath}={}) ->
    @defaultSettings = {}
    @settings = {}
    @configFileHasErrors = false
    @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson'])
    @configFilePath ?= path.join(@configDirPath, 'config.cson')

  initializeConfigDirectory: (done) ->
    return if fs.existsSync(@configDirPath)

    fs.makeTreeSync(@configDirPath)

    queue = async.queue ({sourcePath, destinationPath}, callback) =>
      fs.copy(sourcePath, destinationPath, callback)
    queue.drain = done

    templateConfigDirPath = fs.resolve(@resourcePath, 'dot-atom')
    onConfigDirFile = (sourcePath) =>
      relativePath = sourcePath.substring(templateConfigDirPath.length + 1)
      destinationPath = path.join(@configDirPath, relativePath)
      queue.push({sourcePath, destinationPath})
    fs.traverseTree(templateConfigDirPath, onConfigDirFile, (path) -> true)

  load: ->
    @initializeConfigDirectory()
    @loadUserConfig()
    @observeUserConfig()

  loadUserConfig: ->
    unless fs.existsSync(@configFilePath)
      fs.makeTreeSync(path.dirname(@configFilePath))
      CSON.writeFileSync(@configFilePath, {})

    try
      userConfig = CSON.readFileSync(@configFilePath)
      _.extend(@settings, userConfig)
      @configFileHasErrors = false
      @emit 'updated'
    catch e
      @configFileHasErrors = true
      console.error "Failed to load user config '#{@configFilePath}'", e.message
      console.error e.stack

  observeUserConfig: ->
    @watchSubscription ?= pathWatcher.watch @configFilePath, (eventType) =>
      @loadUserConfig() if eventType is 'change' and @watchSubscription?

  unobserveUserConfig: ->
    @watchSubscription?.close()
    @watchSubscription = null

  setDefaults: (keyPath, defaults) ->
    keys = keyPath.split('.')
    hash = @defaultSettings
    for key in keys
      hash[key] ?= {}
      hash = hash[key]

    _.extend hash, defaults
    @update()

  # Public: Get the path to the config file being used.
  getUserConfigPath: ->
    @configFilePath

  # Public: Returns a new {Object} containing all of settings and defaults.
  getSettings: ->
    _.deepExtend(@settings, @defaultSettings)

  # Public: Retrieves the setting for the given key.
  #
  # keyPath - The {String} name of the key to retrieve
  #
  # Returns the value from Atom's default settings, the user's configuration file,
  # or `null` if the key doesn't exist in either.
  get: (keyPath) ->
    value = _.valueForKeyPath(@settings, keyPath) ? _.valueForKeyPath(@defaultSettings, keyPath)
    _.deepClone(value)

  # Public: Retrieves the setting for the given key as an integer.
  #
  # keyPath - The {String} name of the key to retrieve
  #
  # Returns the value from Atom's default settings, the user's configuration file,
  # or `NaN` if the key doesn't exist in either.
  getInt: (keyPath) ->
    parseInt(@get(keyPath))

  # Public: Retrieves the setting for the given key as a positive integer.
  #
  # keyPath - The {String} name of the key to retrieve
  # defaultValue - The integer {Number} to fall back to if the value isn't
  #                positive, defaults to 0.
  #
  # Returns the value from Atom's default settings, the user's configuration file,
  # or `defaultValue` if the key value isn't greater than zero.
  getPositiveInt: (keyPath, defaultValue=0) ->
    Math.max(@getInt(keyPath), 0) or defaultValue

  # Public: Sets the value for a configuration setting.
  #
  # This value is stored in Atom's internal configuration file.
  #
  # keyPath - The {String} name of the key
  # value - The value of the setting
  #
  # Returns the `value`.
  set: (keyPath, value) ->
    if @get(keyPath) != value
      value = undefined if _.valueForKeyPath(@defaultSettings, keyPath) == value
      _.setValueForKeyPath(@settings, keyPath, value)
      @update()
    value

  # Public: Toggle the value at the key path.
  #
  # The new value will be `true` if the value is currently falsy and will be
  # `false` if the value is currently truthy.
  #
  # Returns the new value.
  toggle: (keyPath) ->
    @set(keyPath, !@get(keyPath))

  # Public: Restore the key path to its default value.
  #
  # keyPath - The {String} name of the key.
  #
  # Returns the new value.
  restoreDefault: (keyPath) ->
    @set(keyPath, _.valueForKeyPath(@defaultSettings, keyPath))

  # Public: Push the value to the array at the key path.
  #
  # keyPath - The {String} key path.
  # value - The value to push to the array.
  #
  # Returns the new array length of the setting.
  pushAtKeyPath: (keyPath, value) ->
    arrayValue = @get(keyPath) ? []
    result = arrayValue.push(value)
    @set(keyPath, arrayValue)
    result

  # Public: Add the value to the beginning of the array at the key path.
  #
  # keyPath - The {String} key path.
  # value - The value to shift onto the array.
  #
  # Returns the new array length of the setting.
  unshiftAtKeyPath: (keyPath, value) ->
    arrayValue = @get(keyPath) ? []
    result = arrayValue.unshift(value)
    @set(keyPath, arrayValue)
    result

  # Public: Remove the value from the array at the key path.
  #
  # keyPath - The {String} key path.
  # value - The value to remove from the array.
  #
  # Returns the new array value of the setting.
  removeAtKeyPath: (keyPath, value) ->
    arrayValue = @get(keyPath) ? []
    result = _.remove(arrayValue, value)
    @set(keyPath, arrayValue)
    result

  # Public: Establishes an event listener for a given key.
  #
  # `callback` is fired whenever the value of the key is changed and will
  #  be fired immediately unless the `callNow` option is `false`.
  #
  # keyPath - The {String} name of the key to observe
  # options - An optional {Object} containing the `callNow` key.
  # callback - The {Function} that fires when the. It is given a single argument, `value`,
  #            which is the new value of `keyPath`.
  #
  # Returns an {Object} with the following keys:
  #  :off - A {Function} that unobserves the `keyPath` with called.
  observe: (keyPath, options={}, callback) ->
    if _.isFunction(options)
      callback = options
      options = {}

    value = @get(keyPath)
    previousValue = _.clone(value)
    updateCallback = =>
      value = @get(keyPath)
      unless _.isEqual(value, previousValue)
        previous = previousValue
        previousValue = _.clone(value)
        callback(value, {previous})

    eventName = "updated.#{keyPath.replace(/\./, '-')}"
    subscription = @on eventName, updateCallback
    callback(value) if options.callNow ? true
    subscription

  # Public: Unobserve all callbacks on a given key
  #
  # keyPath - The {String} name of the key to unobserve
  unobserve: (keyPath) ->
    @off("updated.#{keyPath.replace(/\./, '-')}")

  update: ->
    return if @configFileHasErrors
    @save()
    @emit 'updated'

  save: ->
    CSON.writeFileSync(@configFilePath, @settings)
