_ = require 'underscore-plus'
fs = require 'fs-plus'
{Emitter} = require 'event-kit'
CSON = require 'season'
path = require 'path'
async = require 'async'
{watchPath} = require './path-watcher'
{
  getValueAtKeyPath, setValueAtKeyPath, deleteValueAtKeyPath,
  pushKeyPath, splitKeyPath,
} = require 'key-path-helpers'

Color = require './color'
ScopedPropertyStore = require 'scoped-property-store'
ScopeDescriptor = require './scope-descriptor'

# Essential: Used to access all of Atom's configuration details.
#
# An instance of this class is always available as the `atom.config` global.
#
# ## Getting and setting config settings.
#
# ```coffee
# # Note that with no value set, ::get returns the setting's default value.
# atom.config.get('my-package.myKey') # -> 'defaultValue'
#
# atom.config.set('my-package.myKey', 'value')
# atom.config.get('my-package.myKey') # -> 'value'
# ```
#
# You may want to watch for changes. Use {::observe} to catch changes to the setting.
#
# ```coffee
# atom.config.set('my-package.myKey', 'value')
# atom.config.observe 'my-package.myKey', (newValue) ->
#   # `observe` calls immediately and every time the value is changed
#   console.log 'My configuration changed:', newValue
# ```
#
# If you want a notification only when the value changes, use {::onDidChange}.
#
# ```coffee
# atom.config.onDidChange 'my-package.myKey', ({newValue, oldValue}) ->
#   console.log 'My configuration changed:', newValue, oldValue
# ```
#
# ### Value Coercion
#
# Config settings each have a type specified by way of a
# [schema](json-schema.org). For example we might an integer setting that only
# allows integers greater than `0`:
#
# ```coffee
# # When no value has been set, `::get` returns the setting's default value
# atom.config.get('my-package.anInt') # -> 12
#
# # The string will be coerced to the integer 123
# atom.config.set('my-package.anInt', '123')
# atom.config.get('my-package.anInt') # -> 123
#
# # The string will be coerced to an integer, but it must be greater than 0, so is set to 1
# atom.config.set('my-package.anInt', '-20')
# atom.config.get('my-package.anInt') # -> 1
# ```
#
# ## Defining settings for your package
#
# Define a schema under a `config` key in your package main.
#
# ```coffee
# module.exports =
#   # Your config schema
#   config:
#     someInt:
#       type: 'integer'
#       default: 23
#       minimum: 1
#
#   activate: (state) -> # ...
#   # ...
# ```
#
# See [package docs](http://flight-manual.atom.io/hacking-atom/sections/package-word-count/) for
# more info.
#
# ## Config Schemas
#
# We use [json schema](http://json-schema.org) which allows you to define your value's
# default, the type it should be, etc. A simple example:
#
# ```coffee
# # We want to provide an `enableThing`, and a `thingVolume`
# config:
#   enableThing:
#     type: 'boolean'
#     default: false
#   thingVolume:
#     type: 'integer'
#     default: 5
#     minimum: 1
#     maximum: 11
# ```
#
# The type keyword allows for type coercion and validation. If a `thingVolume` is
# set to a string `'10'`, it will be coerced into an integer.
#
# ```coffee
# atom.config.set('my-package.thingVolume', '10')
# atom.config.get('my-package.thingVolume') # -> 10
#
# # It respects the min / max
# atom.config.set('my-package.thingVolume', '400')
# atom.config.get('my-package.thingVolume') # -> 11
#
# # If it cannot be coerced, the value will not be set
# atom.config.set('my-package.thingVolume', 'cats')
# atom.config.get('my-package.thingVolume') # -> 11
# ```
#
# ### Supported Types
#
# The `type` keyword can be a string with any one of the following. You can also
# chain them by specifying multiple in an an array. For example
#
# ```coffee
# config:
#   someSetting:
#     type: ['boolean', 'integer']
#     default: 5
#
# # Then
# atom.config.set('my-package.someSetting', 'true')
# atom.config.get('my-package.someSetting') # -> true
#
# atom.config.set('my-package.someSetting', '12')
# atom.config.get('my-package.someSetting') # -> 12
# ```
#
# #### string
#
# Values must be a string.
#
# ```coffee
# config:
#   someSetting:
#     type: 'string'
#     default: 'hello'
# ```
#
# #### integer
#
# Values will be coerced into integer. Supports the (optional) `minimum` and
# `maximum` keys.
#
#   ```coffee
#   config:
#     someSetting:
#       type: 'integer'
#       default: 5
#       minimum: 1
#       maximum: 11
#   ```
#
# #### number
#
# Values will be coerced into a number, including real numbers. Supports the
# (optional) `minimum` and `maximum` keys.
#
# ```coffee
# config:
#   someSetting:
#     type: 'number'
#     default: 5.3
#     minimum: 1.5
#     maximum: 11.5
# ```
#
# #### boolean
#
# Values will be coerced into a Boolean. `'true'` and `'false'` will be coerced into
# a boolean. Numbers, arrays, objects, and anything else will not be coerced.
#
# ```coffee
# config:
#   someSetting:
#     type: 'boolean'
#     default: false
# ```
#
# #### array
#
# Value must be an Array. The types of the values can be specified by a
# subschema in the `items` key.
#
# ```coffee
# config:
#   someSetting:
#     type: 'array'
#     default: [1, 2, 3]
#     items:
#       type: 'integer'
#       minimum: 1.5
#       maximum: 11.5
# ```
#
# #### color
#
# Values will be coerced into a {Color} with `red`, `green`, `blue`, and `alpha`
# properties that all have numeric values. `red`, `green`, `blue` will be in
# the range 0 to 255 and `value` will be in the range 0 to 1. Values can be any
# valid CSS color format such as `#abc`, `#abcdef`, `white`,
# `rgb(50, 100, 150)`, and `rgba(25, 75, 125, .75)`.
#
# ```coffee
# config:
#   someSetting:
#     type: 'color'
#     default: 'white'
# ```
#
# #### object / Grouping other types
#
# A config setting with the type `object` allows grouping a set of config
# settings. The group will be visually separated and has its own group headline.
# The sub options must be listed under a `properties` key.
#
# ```coffee
# config:
#   someSetting:
#     type: 'object'
#     properties:
#       myChildIntOption:
#         type: 'integer'
#         minimum: 1.5
#         maximum: 11.5
# ```
#
# ### Other Supported Keys
#
# #### enum
#
# All types support an `enum` key, which lets you specify all the values the
# setting can take. `enum` may be an array of allowed values (of the specified
# type), or an array of objects with `value` and `description` properties, where
# the `value` is an allowed value, and the `description` is a descriptive string
# used in the settings view.
#
# In this example, the setting must be one of the 4 integers:
#
# ```coffee
# config:
#   someSetting:
#     type: 'integer'
#     default: 4
#     enum: [2, 4, 6, 8]
# ```
#
# In this example, the setting must be either 'foo' or 'bar', which are
# presented using the provided descriptions in the settings pane:
#
# ```coffee
# config:
#   someSetting:
#     type: 'string'
#     default: 'foo'
#     enum: [
#       {value: 'foo', description: 'Foo mode. You want this.'}
#       {value: 'bar', description: 'Bar mode. Nobody wants that!'}
#     ]
# ```
#
# Usage:
#
# ```coffee
# atom.config.set('my-package.someSetting', '2')
# atom.config.get('my-package.someSetting') # -> 2
#
# # will not set values outside of the enum values
# atom.config.set('my-package.someSetting', '3')
# atom.config.get('my-package.someSetting') # -> 2
#
# # If it cannot be coerced, the value will not be set
# atom.config.set('my-package.someSetting', '4')
# atom.config.get('my-package.someSetting') # -> 4
# ```
#
# #### title and description
#
# The settings view will use the `title` and `description` keys to display your
# config setting in a readable way. By default the settings view humanizes your
# config key, so `someSetting` becomes `Some Setting`. In some cases, this is
# confusing for users, and a more descriptive title is useful.
#
# Descriptions will be displayed below the title in the settings view.
#
# For a group of config settings the humanized key or the title and the
# description are used for the group headline.
#
# ```coffee
# config:
#   someSetting:
#     title: 'Setting Magnitude'
#     description: 'This will affect the blah and the other blah'
#     type: 'integer'
#     default: 4
# ```
#
# __Note__: You should strive to be so clear in your naming of the setting that
# you do not need to specify a title or description!
#
# Descriptions allow a subset of
# [Markdown formatting](https://help.github.com/articles/github-flavored-markdown/).
# Specifically, you may use the following in configuration setting descriptions:
#
# * **bold** - `**bold**`
# * *italics* - `*italics*`
# * [links](https://atom.io) - `[links](https://atom.io)`
# * `code spans` - `\`code spans\``
# * line breaks - `line breaks<br/>`
# * ~~strikethrough~~ - `~~strikethrough~~`
#
# #### order
#
# The settings view orders your settings alphabetically. You can override this
# ordering with the order key.
#
# ```coffee
# config:
#   zSetting:
#     type: 'integer'
#     default: 4
#     order: 1
#   aSetting:
#     type: 'integer'
#     default: 4
#     order: 2
# ```
#
# ## Manipulating values outside your configuration schema
#
# It is possible to manipulate(`get`, `set`, `observe` etc) values that do not
# appear in your configuration schema. For example, if the config schema of the
# package 'some-package' is
#
# ```coffee
# config:
# someSetting:
#   type: 'boolean'
#   default: false
# ```
#
# You can still do the following
#
# ```coffee
# let otherSetting  = atom.config.get('some-package.otherSetting')
# atom.config.set('some-package.stillAnotherSetting', otherSetting * 5)
# ```
#
# In other words, if a function asks for a `key-path`, that path doesn't have to
# be described in the config schema for the package or any package. However, as
# highlighted in the best practices section, you are advised against doing the
# above.
#
# ## Best practices
#
# * Don't depend on (or write to) configuration keys outside of your keypath.
#
module.exports =
class Config
  @schemaEnforcers = {}

  @addSchemaEnforcer: (typeName, enforcerFunction) ->
    @schemaEnforcers[typeName] ?= []
    @schemaEnforcers[typeName].push(enforcerFunction)

  @addSchemaEnforcers: (filters) ->
    for typeName, functions of filters
      for name, enforcerFunction of functions
        @addSchemaEnforcer(typeName, enforcerFunction)
    return

  @executeSchemaEnforcers: (keyPath, value, schema) ->
    error = null
    types = schema.type
    types = [types] unless Array.isArray(types)
    for type in types
      try
        enforcerFunctions = @schemaEnforcers[type].concat(@schemaEnforcers['*'])
        for enforcer in enforcerFunctions
          # At some point in one's life, one must call upon an enforcer.
          value = enforcer.call(this, keyPath, value, schema)
        error = null
        break
      catch e
        error = e

    throw error if error?
    value

  # Created during initialization, available as `atom.config`
  constructor: ({@notificationManager, @enablePersistence}={}) ->
    @clear()

  initialize: ({@configDirPath, @resourcePath, projectHomeSchema}) ->
    if @enablePersistence?
      @configFilePath = fs.resolve(@configDirPath, 'config', ['json', 'cson'])
      @configFilePath ?= path.join(@configDirPath, 'config.cson')

    @schema.properties.core.properties.projectHome = projectHomeSchema
    @defaultSettings.core.projectHome = projectHomeSchema.default

  clear: ->
    @emitter = new Emitter
    @schema =
      type: 'object'
      properties: {}
    @defaultSettings = {}
    @settings = {}
    @scopedSettingsStore = new ScopedPropertyStore

    @settingsLoaded = false
    @savePending = false
    @configFileHasErrors = false
    @transactDepth = 0
    @pendingOperations = []

    @requestLoad = _.debounce =>
      @loadUserConfig()
    , 100

    debouncedSave = _.debounce =>
      @savePending = false
      @save()
    , 100
    @requestSave = =>
      @savePending = true
      debouncedSave()

  shouldNotAccessFileSystem: -> not @enablePersistence

  ###
  Section: Config Subscription
  ###

  # Essential: Add a listener for changes to a given key path. This is different
  # than {::onDidChange} in that it will immediately call your callback with the
  # current value of the config entry.
  #
  # ### Examples
  #
  # You might want to be notified when the themes change. We'll watch
  # `core.themes` for changes
  #
  # ```coffee
  # atom.config.observe 'core.themes', (value) ->
  #   # do stuff with value
  # ```
  #
  # * `keyPath` {String} name of the key to observe
  # * `options` (optional) {Object}
  #   * `scope` (optional) {ScopeDescriptor} describing a path from
  #     the root of the syntax tree to a token. Get one by calling
  #     {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
  #     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  #     for more information.
  # * `callback` {Function} to call when the value of the key changes.
  #   * `value` the new value of the key
  #
  # Returns a {Disposable} with the following keys on which you can call
  # `.dispose()` to unsubscribe.
  observe: ->
    if arguments.length is 2
      [keyPath, callback] = arguments
    else if arguments.length is 3 and (_.isString(arguments[0]) and _.isObject(arguments[1]))
      [keyPath, options, callback] = arguments
      scopeDescriptor = options.scope
    else
      console.error 'An unsupported form of Config::observe is being used. See https://atom.io/docs/api/latest/Config for details'
      return

    if scopeDescriptor?
      @observeScopedKeyPath(scopeDescriptor, keyPath, callback)
    else
      @observeKeyPath(keyPath, options ? {}, callback)

  # Essential: Add a listener for changes to a given key path. If `keyPath` is
  # not specified, your callback will be called on changes to any key.
  #
  # * `keyPath` (optional) {String} name of the key to observe. Must be
  #   specified if `scopeDescriptor` is specified.
  # * `options` (optional) {Object}
  #   * `scope` (optional) {ScopeDescriptor} describing a path from
  #     the root of the syntax tree to a token. Get one by calling
  #     {editor.getLastCursor().getScopeDescriptor()}. See {::get} for examples.
  #     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  #     for more information.
  # * `callback` {Function} to call when the value of the key changes.
  #   * `event` {Object}
  #     * `newValue` the new value of the key
  #     * `oldValue` the prior value of the key.
  #
  # Returns a {Disposable} with the following keys on which you can call
  # `.dispose()` to unsubscribe.
  onDidChange: ->
    if arguments.length is 1
      [callback] = arguments
    else if arguments.length is 2
      [keyPath, callback] = arguments
    else
      [keyPath, options, callback] = arguments
      scopeDescriptor = options.scope

    if scopeDescriptor?
      @onDidChangeScopedKeyPath(scopeDescriptor, keyPath, callback)
    else
      @onDidChangeKeyPath(keyPath, callback)

  ###
  Section: Managing Settings
  ###

  # Essential: Retrieves the setting for the given key.
  #
  # ### Examples
  #
  # You might want to know what themes are enabled, so check `core.themes`
  #
  # ```coffee
  # atom.config.get('core.themes')
  # ```
  #
  # With scope descriptors you can get settings within a specific editor
  # scope. For example, you might want to know `editor.tabLength` for ruby
  # files.
  #
  # ```coffee
  # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
  # ```
  #
  # This setting in ruby files might be different than the global tabLength setting
  #
  # ```coffee
  # atom.config.get('editor.tabLength') # => 4
  # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
  # ```
  #
  # You can get the language scope descriptor via
  # {TextEditor::getRootScopeDescriptor}. This will get the setting specifically
  # for the editor's language.
  #
  # ```coffee
  # atom.config.get('editor.tabLength', scope: @editor.getRootScopeDescriptor()) # => 2
  # ```
  #
  # Additionally, you can get the setting at the specific cursor position.
  #
  # ```coffee
  # scopeDescriptor = @editor.getLastCursor().getScopeDescriptor()
  # atom.config.get('editor.tabLength', scope: scopeDescriptor) # => 2
  # ```
  #
  # * `keyPath` The {String} name of the key to retrieve.
  # * `options` (optional) {Object}
  #   * `sources` (optional) {Array} of {String} source names. If provided, only
  #     values that were associated with these sources during {::set} will be used.
  #   * `excludeSources` (optional) {Array} of {String} source names. If provided,
  #     values that  were associated with these sources during {::set} will not
  #     be used.
  #   * `scope` (optional) {ScopeDescriptor} describing a path from
  #     the root of the syntax tree to a token. Get one by calling
  #     {editor.getLastCursor().getScopeDescriptor()}
  #     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  #     for more information.
  #
  # Returns the value from Atom's default settings, the user's configuration
  # file in the type specified by the configuration schema.
  get: ->
    if arguments.length > 1
      if typeof arguments[0] is 'string' or not arguments[0]?
        [keyPath, options] = arguments
        {scope} = options
    else
      [keyPath] = arguments

    if scope?
      value = @getRawScopedValue(scope, keyPath, options)
      value ? @getRawValue(keyPath, options)
    else
      @getRawValue(keyPath, options)

  # Extended: Get all of the values for the given key-path, along with their
  # associated scope selector.
  #
  # * `keyPath` The {String} name of the key to retrieve
  # * `options` (optional) {Object} see the `options` argument to {::get}
  #
  # Returns an {Array} of {Object}s with the following keys:
  #  * `scopeDescriptor` The {ScopeDescriptor} with which the value is associated
  #  * `value` The value for the key-path
  getAll: (keyPath, options) ->
    {scope} = options if options?
    result = []

    if scope?
      scopeDescriptor = ScopeDescriptor.fromObject(scope)
      result = result.concat @scopedSettingsStore.getAll(scopeDescriptor.getScopeChain(), keyPath, options)

    if globalValue = @getRawValue(keyPath, options)
      result.push(scopeSelector: '*', value: globalValue)

    result

  # Essential: Sets the value for a configuration setting.
  #
  # This value is stored in Atom's internal configuration file.
  #
  # ### Examples
  #
  # You might want to change the themes programmatically:
  #
  # ```coffee
  # atom.config.set('core.themes', ['atom-light-ui', 'atom-light-syntax'])
  # ```
  #
  # You can also set scoped settings. For example, you might want change the
  # `editor.tabLength` only for ruby files.
  #
  # ```coffee
  # atom.config.get('editor.tabLength') # => 4
  # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 4
  # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
  #
  # # Set ruby to 2
  # atom.config.set('editor.tabLength', 2, scopeSelector: '.source.ruby') # => true
  #
  # # Notice it's only set to 2 in the case of ruby
  # atom.config.get('editor.tabLength') # => 4
  # atom.config.get('editor.tabLength', scope: ['source.ruby']) # => 2
  # atom.config.get('editor.tabLength', scope: ['source.js']) # => 4
  # ```
  #
  # * `keyPath` The {String} name of the key.
  # * `value` The value of the setting. Passing `undefined` will revert the
  #   setting to the default value.
  # * `options` (optional) {Object}
  #   * `scopeSelector` (optional) {String}. eg. '.source.ruby'
  #     See [the scopes docs](http://flight-manual.atom.io/behind-atom/sections/scoped-settings-scopes-and-scope-descriptors/)
  #     for more information.
  #   * `source` (optional) {String} The name of a file with which the setting
  #     is associated. Defaults to the user's config file.
  #
  # Returns a {Boolean}
  # * `true` if the value was set.
  # * `false` if the value was not able to be coerced to the type specified in the setting's schema.
  set: ->
    [keyPath, value, options] = arguments

    unless @settingsLoaded
      @pendingOperations.push => @set.call(this, keyPath, value, options)

    scopeSelector = options?.scopeSelector
    source = options?.source
    shouldSave = options?.save ? true

    if source and not scopeSelector
      throw new Error("::set with a 'source' and no 'sourceSelector' is not yet implemented!")

    source ?= @getUserConfigPath()

    unless value is undefined
      try
        value = @makeValueConformToSchema(keyPath, value)
      catch e
        return false

    if scopeSelector?
      @setRawScopedValue(keyPath, value, source, scopeSelector)
    else
      @setRawValue(keyPath, value)

    if source is @getUserConfigPath() and shouldSave and not @configFileHasErrors and @settingsLoaded
      @requestSave()
    true

  # Essential: Restore the setting at `keyPath` to its default value.
  #
  # * `keyPath` The {String} name of the key.
  # * `options` (optional) {Object}
  #   * `scopeSelector` (optional) {String}. See {::set}
  #   * `source` (optional) {String}. See {::set}
  unset: (keyPath, options) ->
    unless @settingsLoaded
      @pendingOperations.push => @unset.call(this, keyPath, options)

    {scopeSelector, source} = options ? {}
    source ?= @getUserConfigPath()

    if scopeSelector?
      if keyPath?
        settings = @scopedSettingsStore.propertiesForSourceAndSelector(source, scopeSelector)
        if getValueAtKeyPath(settings, keyPath)?
          @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector)
          setValueAtKeyPath(settings, keyPath, undefined)
          settings = withoutEmptyObjects(settings)
          @set(null, settings, {scopeSelector, source, priority: @priorityForSource(source)}) if settings?
          if source is @getUserConfigPath() and not @configFileHasErrors and @settingsLoaded
            @requestSave()
      else
        @scopedSettingsStore.removePropertiesForSourceAndSelector(source, scopeSelector)
        @emitChangeEvent()
    else
      for scopeSelector of @scopedSettingsStore.propertiesForSource(source)
        @unset(keyPath, {scopeSelector, source})
      if keyPath? and source is @getUserConfigPath()
        @set(keyPath, getValueAtKeyPath(@defaultSettings, keyPath))

  # Extended: Get an {Array} of all of the `source` {String}s with which
  # settings have been added via {::set}.
  getSources: ->
    _.uniq(_.pluck(@scopedSettingsStore.propertySets, 'source')).sort()

  # Extended: Retrieve the schema for a specific key path. The schema will tell
  # you what type the keyPath expects, and other metadata about the config
  # option.
  #
  # * `keyPath` The {String} name of the key.
  #
  # Returns an {Object} eg. `{type: 'integer', default: 23, minimum: 1}`.
  # Returns `null` when the keyPath has no schema specified, but is accessible
  # from the root schema.
  getSchema: (keyPath) ->
    keys = splitKeyPath(keyPath)
    schema = @schema
    for key in keys
      if schema.type is 'object'
        childSchema = schema.properties?[key]
        unless childSchema?
          if isPlainObject(schema.additionalProperties)
            childSchema = schema.additionalProperties
          else if schema.additionalProperties is false
            return null
          else
            return {type: 'any'}
      else
        return null
      schema = childSchema
    schema

  # Extended: Get the {String} path to the config file being used.
  getUserConfigPath: ->
    @configFilePath

  # Extended: Suppress calls to handler functions registered with {::onDidChange}
  # and {::observe} for the duration of `callback`. After `callback` executes,
  # handlers will be called once if the value for their key-path has changed.
  #
  # * `callback` {Function} to execute while suppressing calls to handlers.
  transact: (callback) ->
    @beginTransaction()
    try
      callback()
    finally
      @endTransaction()

  ###
  Section: Internal methods used by core
  ###

  # Private: Suppress calls to handler functions registered with {::onDidChange}
  # and {::observe} for the duration of the {Promise} returned by `callback`.
  # After the {Promise} is either resolved or rejected, handlers will be called
  # once if the value for their key-path has changed.
  #
  # * `callback` {Function} that returns a {Promise}, which will be executed
  #   while suppressing calls to handlers.
  #
  # Returns a {Promise} that is either resolved or rejected according to the
  # `{Promise}` returned by `callback`. If `callback` throws an error, a
  # rejected {Promise} will be returned instead.
  transactAsync: (callback) ->
    @beginTransaction()
    try
      endTransaction = (fn) => (args...) =>
        @endTransaction()
        fn(args...)
      result = callback()
      new Promise (resolve, reject) ->
        result.then(endTransaction(resolve)).catch(endTransaction(reject))
    catch error
      @endTransaction()
      Promise.reject(error)

  beginTransaction: ->
    @transactDepth++

  endTransaction: ->
    @transactDepth--
    @emitChangeEvent()

  pushAtKeyPath: (keyPath, value) ->
    arrayValue = @get(keyPath) ? []
    result = arrayValue.push(value)
    @set(keyPath, arrayValue)
    result

  unshiftAtKeyPath: (keyPath, value) ->
    arrayValue = @get(keyPath) ? []
    result = arrayValue.unshift(value)
    @set(keyPath, arrayValue)
    result

  removeAtKeyPath: (keyPath, value) ->
    arrayValue = @get(keyPath) ? []
    result = _.remove(arrayValue, value)
    @set(keyPath, arrayValue)
    result

  setSchema: (keyPath, schema) ->
    unless isPlainObject(schema)
      throw new Error("Error loading schema for #{keyPath}: schemas can only be objects!")

    unless typeof schema.type?
      throw new Error("Error loading schema for #{keyPath}: schema objects must have a type attribute")

    rootSchema = @schema
    if keyPath
      for key in splitKeyPath(keyPath)
        rootSchema.type = 'object'
        rootSchema.properties ?= {}
        properties = rootSchema.properties
        properties[key] ?= {}
        rootSchema = properties[key]

    Object.assign rootSchema, schema
    @transact =>
      @setDefaults(keyPath, @extractDefaultsFromSchema(schema))
      @setScopedDefaultsFromSchema(keyPath, schema)
      @resetSettingsForSchemaChange()

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

  ###
  Section: Private methods managing the user's config file
  ###

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

    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), (->))

  loadUserConfig: ->
    return if @shouldNotAccessFileSystem()
    return if @savePending

    try
      unless fs.existsSync(@configFilePath)
        fs.makeTreeSync(path.dirname(@configFilePath))
        CSON.writeFileSync(@configFilePath, {}, {flag: 'wx'}) # fails if file exists
    catch error
      if error.code isnt 'EEXIST'
        @configFileHasErrors = true
        @notifyFailure("Failed to initialize `#{path.basename(@configFilePath)}`", error.stack)
        return

    try
      userConfig = CSON.readFileSync(@configFilePath)
      userConfig = {} if userConfig is null

      unless isPlainObject(userConfig)
        throw new Error("`#{path.basename(@configFilePath)}` must contain valid JSON or CSON")

      @resetUserSettings(userConfig)
      @configFileHasErrors = false
    catch error
      @configFileHasErrors = true
      message = "Failed to load `#{path.basename(@configFilePath)}`"

      detail = if error.location?
        # stack is the output from CSON in this case
        error.stack
      else
        # message will be EACCES permission denied, et al
        error.message

      @notifyFailure(message, detail)

  observeUserConfig: ->
    return if @shouldNotAccessFileSystem()

    try
      @watchSubscriptionPromise ?= watchPath @configFilePath, {}, (events) =>
        for {action} in events
          if action in ['created', 'modified', 'renamed'] and @watchSubscriptionPromise?
            @requestLoad()
    catch error
      @notifyFailure """
        Unable to watch path: `#{path.basename(@configFilePath)}`. Make sure you have permissions to
        `#{@configFilePath}`. On linux there are currently problems with watch
        sizes. See [this document][watches] for more info.
        [watches]:https://github.com/atom/atom/blob/master/docs/build-instructions/linux.md#typeerror-unable-to-watch-path
      """

    @watchSubscriptionPromise

  unobserveUserConfig: ->
    @watchSubscriptionPromise?.then (watcher) -> watcher?.dispose()
    @watchSubscriptionPromise = null

  notifyFailure: (errorMessage, detail) ->
    @notificationManager?.addError(errorMessage, {detail, dismissable: true})

  save: ->
    return if @shouldNotAccessFileSystem()

    allSettings = {'*': @settings}
    allSettings = Object.assign allSettings, @scopedSettingsStore.propertiesForSource(@getUserConfigPath())
    allSettings = sortObject(allSettings)
    try
      CSON.writeFileSync(@configFilePath, allSettings)
    catch error
      message = "Failed to save `#{path.basename(@configFilePath)}`"
      detail = error.message
      @notifyFailure(message, detail)

  ###
  Section: Private methods managing global settings
  ###

  resetUserSettings: (newSettings) ->
    if newSettings.global?
      newSettings['*'] = newSettings.global
      delete newSettings.global

    if newSettings['*']?
      scopedSettings = newSettings
      newSettings = newSettings['*']
      delete scopedSettings['*']
      @resetUserScopedSettings(scopedSettings)

    @transact =>
      @settings = {}
      @settingsLoaded = true
      @set(key, value, save: false) for key, value of newSettings
      if @pendingOperations.length
        op() for op in @pendingOperations
        @pendingOperations = []

  getRawValue: (keyPath, options) ->
    unless options?.excludeSources?.indexOf(@getUserConfigPath()) >= 0
      value = getValueAtKeyPath(@settings, keyPath)
    unless options?.sources?.length > 0
      defaultValue = getValueAtKeyPath(@defaultSettings, keyPath)

    if value?
      value = @deepClone(value)
      @deepDefaults(value, defaultValue) if isPlainObject(value) and isPlainObject(defaultValue)
    else
      value = @deepClone(defaultValue)

    value

  setRawValue: (keyPath, value) ->
    defaultValue = getValueAtKeyPath(@defaultSettings, keyPath)
    if _.isEqual(defaultValue, value)
      if keyPath?
        deleteValueAtKeyPath(@settings, keyPath)
      else
        @settings = null
    else
      if keyPath?
        setValueAtKeyPath(@settings, keyPath, value)
      else
        @settings = value
    @emitChangeEvent()

  observeKeyPath: (keyPath, options, callback) ->
    callback(@get(keyPath))
    @onDidChangeKeyPath keyPath, (event) -> callback(event.newValue)

  onDidChangeKeyPath: (keyPath, callback) ->
    oldValue = @get(keyPath)
    @emitter.on 'did-change', =>
      newValue = @get(keyPath)
      unless _.isEqual(oldValue, newValue)
        event = {oldValue, newValue}
        oldValue = newValue
        callback(event)

  isSubKeyPath: (keyPath, subKeyPath) ->
    return false unless keyPath? and subKeyPath?
    pathSubTokens = splitKeyPath(subKeyPath)
    pathTokens = splitKeyPath(keyPath).slice(0, pathSubTokens.length)
    _.isEqual(pathTokens, pathSubTokens)

  setRawDefault: (keyPath, value) ->
    setValueAtKeyPath(@defaultSettings, keyPath, value)
    @emitChangeEvent()

  setDefaults: (keyPath, defaults) ->
    if defaults? and isPlainObject(defaults)
      keys = splitKeyPath(keyPath)
      @transact =>
        for key, childValue of defaults
          continue unless defaults.hasOwnProperty(key)
          @setDefaults(keys.concat([key]).join('.'), childValue)
    else
      try
        defaults = @makeValueConformToSchema(keyPath, defaults)
        @setRawDefault(keyPath, defaults)
      catch e
        console.warn("'#{keyPath}' could not set the default. Attempted default: #{JSON.stringify(defaults)}; Schema: #{JSON.stringify(@getSchema(keyPath))}")
    return

  deepClone: (object) ->
    if object instanceof Color
      object.clone()
    else if _.isArray(object)
      object.map (value) => @deepClone(value)
    else if isPlainObject(object)
      _.mapObject object, (key, value) => [key, @deepClone(value)]
    else
      object

  deepDefaults: (target) ->
    result = target
    i = 0
    while ++i < arguments.length
      object = arguments[i]
      if isPlainObject(result) and isPlainObject(object)
        for key in Object.keys(object)
          result[key] = @deepDefaults(result[key], object[key])
      else
        if not result?
          result = @deepClone(object)
    result

  # `schema` will look something like this
  #
  # ```coffee
  # type: 'string'
  # default: 'ok'
  # scopes:
  #   '.source.js':
  #     default: 'omg'
  # ```
  setScopedDefaultsFromSchema: (keyPath, schema) ->
    if schema.scopes? and isPlainObject(schema.scopes)
      scopedDefaults = {}
      for scope, scopeSchema of schema.scopes
        continue unless scopeSchema.hasOwnProperty('default')
        scopedDefaults[scope] = {}
        setValueAtKeyPath(scopedDefaults[scope], keyPath, scopeSchema.default)
      @scopedSettingsStore.addProperties('schema-default', scopedDefaults)

    if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties)
      keys = splitKeyPath(keyPath)
      for key, childValue of schema.properties
        continue unless schema.properties.hasOwnProperty(key)
        @setScopedDefaultsFromSchema(keys.concat([key]).join('.'), childValue)

    return

  extractDefaultsFromSchema: (schema) ->
    if schema.default?
      schema.default
    else if schema.type is 'object' and schema.properties? and isPlainObject(schema.properties)
      defaults = {}
      properties = schema.properties or {}
      defaults[key] = @extractDefaultsFromSchema(value) for key, value of properties
      defaults

  makeValueConformToSchema: (keyPath, value, options) ->
    if options?.suppressException
      try
        @makeValueConformToSchema(keyPath, value)
      catch e
        undefined
    else
      unless (schema = @getSchema(keyPath))?
        throw new Error("Illegal key path #{keyPath}") if schema is false
      @constructor.executeSchemaEnforcers(keyPath, value, schema)

  # When the schema is changed / added, there may be values set in the config
  # that do not conform to the schema. This will reset make them conform.
  resetSettingsForSchemaChange: (source=@getUserConfigPath()) ->
    @transact =>
      @settings = @makeValueConformToSchema(null, @settings, suppressException: true)
      selectorsAndSettings = @scopedSettingsStore.propertiesForSource(source)
      @scopedSettingsStore.removePropertiesForSource(source)
      for scopeSelector, settings of selectorsAndSettings
        settings = @makeValueConformToSchema(null, settings, suppressException: true)
        @setRawScopedValue(null, settings, source, scopeSelector)
      return

  ###
  Section: Private Scoped Settings
  ###

  priorityForSource: (source) ->
    if source is @getUserConfigPath()
      1000
    else
      0

  emitChangeEvent: ->
    @emitter.emit 'did-change' unless @transactDepth > 0

  resetUserScopedSettings: (newScopedSettings) ->
    source = @getUserConfigPath()
    priority = @priorityForSource(source)
    @scopedSettingsStore.removePropertiesForSource(source)

    for scopeSelector, settings of newScopedSettings
      settings = @makeValueConformToSchema(null, settings, suppressException: true)
      validatedSettings = {}
      validatedSettings[scopeSelector] = withoutEmptyObjects(settings)
      @scopedSettingsStore.addProperties(source, validatedSettings, {priority}) if validatedSettings[scopeSelector]?

    @emitChangeEvent()

  setRawScopedValue: (keyPath, value, source, selector, options) ->
    if keyPath?
      newValue = {}
      setValueAtKeyPath(newValue, keyPath, value)
      value = newValue

    settingsBySelector = {}
    settingsBySelector[selector] = value
    @scopedSettingsStore.addProperties(source, settingsBySelector, priority: @priorityForSource(source))
    @emitChangeEvent()

  getRawScopedValue: (scopeDescriptor, keyPath, options) ->
    scopeDescriptor = ScopeDescriptor.fromObject(scopeDescriptor)
    @scopedSettingsStore.getPropertyValue(scopeDescriptor.getScopeChain(), keyPath, options)

  observeScopedKeyPath: (scope, keyPath, callback) ->
    callback(@get(keyPath, {scope}))
    @onDidChangeScopedKeyPath scope, keyPath, (event) -> callback(event.newValue)

  onDidChangeScopedKeyPath: (scope, keyPath, callback) ->
    oldValue = @get(keyPath, {scope})
    @emitter.on 'did-change', =>
      newValue = @get(keyPath, {scope})
      unless _.isEqual(oldValue, newValue)
        event = {oldValue, newValue}
        oldValue = newValue
        callback(event)

# Base schema enforcers. These will coerce raw input into the specified type,
# and will throw an error when the value cannot be coerced. Throwing the error
# will indicate that the value should not be set.
#
# Enforcers are run from most specific to least. For a schema with type
# `integer`, all the enforcers for the `integer` type will be run first, in
# order of specification. Then the `*` enforcers will be run, in order of
# specification.
Config.addSchemaEnforcers
  'any':
    coerce: (keyPath, value, schema) ->
      value

  'integer':
    coerce: (keyPath, value, schema) ->
      value = parseInt(value)
      throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into an int") if isNaN(value) or not isFinite(value)
      value

  'number':
    coerce: (keyPath, value, schema) ->
      value = parseFloat(value)
      throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a number") if isNaN(value) or not isFinite(value)
      value

  'boolean':
    coerce: (keyPath, value, schema) ->
      switch typeof value
        when 'string'
          if value.toLowerCase() is 'true'
            true
          else if value.toLowerCase() is 'false'
            false
          else
            throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'")
        when 'boolean'
          value
        else
          throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a boolean or the string 'true' or 'false'")

  'string':
    validate: (keyPath, value, schema) ->
      unless typeof value is 'string'
        throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be a string")
      value

    validateMaximumLength: (keyPath, value, schema) ->
      if typeof schema.maximumLength is 'number' and value.length > schema.maximumLength
        value.slice(0, schema.maximumLength)
      else
        value

  'null':
    # null sort of isnt supported. It will just unset in this case
    coerce: (keyPath, value, schema) ->
      throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be null") unless value in [undefined, null]
      value

  'object':
    coerce: (keyPath, value, schema) ->
      throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an object") unless isPlainObject(value)
      return value unless schema.properties?

      defaultChildSchema = null
      allowsAdditionalProperties = true
      if isPlainObject(schema.additionalProperties)
        defaultChildSchema = schema.additionalProperties
      if schema.additionalProperties is false
        allowsAdditionalProperties = false

      newValue = {}
      for prop, propValue of value
        childSchema = schema.properties[prop] ? defaultChildSchema
        if childSchema?
          try
            newValue[prop] = @executeSchemaEnforcers(pushKeyPath(keyPath, prop), propValue, childSchema)
          catch error
            console.warn "Error setting item in object: #{error.message}"
        else if allowsAdditionalProperties
          # Just pass through un-schema'd values
          newValue[prop] = propValue
        else
          console.warn "Illegal object key: #{keyPath}.#{prop}"

      newValue

  'array':
    coerce: (keyPath, value, schema) ->
      throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} must be an array") unless Array.isArray(value)
      itemSchema = schema.items
      if itemSchema?
        newValue = []
        for item in value
          try
            newValue.push @executeSchemaEnforcers(keyPath, item, itemSchema)
          catch error
            console.warn "Error setting item in array: #{error.message}"
        newValue
      else
        value

  'color':
    coerce: (keyPath, value, schema) ->
      color = Color.parse(value)
      unless color?
        throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} cannot be coerced into a color")
      color

  '*':
    coerceMinimumAndMaximum: (keyPath, value, schema) ->
      return value unless typeof value is 'number'
      if schema.minimum? and typeof schema.minimum is 'number'
        value = Math.max(value, schema.minimum)
      if schema.maximum? and typeof schema.maximum is 'number'
        value = Math.min(value, schema.maximum)
      value

    validateEnum: (keyPath, value, schema) ->
      possibleValues = schema.enum

      if Array.isArray(possibleValues)
        possibleValues = possibleValues.map (value) ->
          if value.hasOwnProperty('value') then value.value else value

      return value unless possibleValues? and Array.isArray(possibleValues) and possibleValues.length

      for possibleValue in possibleValues
        # Using `isEqual` for possibility of placing enums on array and object schemas
        return value if _.isEqual(possibleValue, value)

      throw new Error("Validation failed at #{keyPath}, #{JSON.stringify(value)} is not one of #{JSON.stringify(possibleValues)}")

isPlainObject = (value) ->
  _.isObject(value) and not _.isArray(value) and not _.isFunction(value) and not _.isString(value) and not (value instanceof Color)

sortObject = (value) ->
  return value unless isPlainObject(value)
  result = {}
  for key in Object.keys(value).sort()
    result[key] = sortObject(value[key])
  result

withoutEmptyObjects = (object) ->
  resultObject = undefined
  if isPlainObject(object)
    for key, value of object
      newValue = withoutEmptyObjects(value)
      if newValue?
        resultObject ?= {}
        resultObject[key] = newValue
  else
    resultObject = object
  resultObject
