{Emitter, Disposable, CompositeDisposable} = require 'event-kit'
{specificity} = require 'clear-cut'
_ = require 'underscore'
_str = require 'underscore.string'

SequenceCount = 0
SpecificityCache = {}

# Public: Associates listener functions with commands in a
# context-sensitive way using CSS selectors. You can access a global instance of
# this class via `atom.commands`, and commands registered there will be
# presented in the command palette.
#
# The global command registry facilitates a style of event handling known as
# *event delegation* that was popularized by jQuery. Atom commands are expressed
# as custom DOM events that can be invoked on the currently focused element via
# a key binding or manually via the command palette. Rather than binding
# listeners for command events directly to DOM nodes, you instead register
# command event listeners globally on `atom.commands` and constrain them to
# specific kinds of elements with CSS selectors.
#
# As the event bubbles upward through the DOM, all registered event listeners
# with matching selectors are invoked in order of specificity. In the event of a
# specificity tie, the most recently registered listener is invoked first. This
# mirrors the "cascade" semantics of CSS. Event listeners are invoked in the
# context of the current DOM node, meaning `this` always points at
# `event.currentTarget`. As is normally the case with DOM events,
# `stopPropagation` and `stopImmediatePropagation` can be used to terminate the
# bubbling process and prevent invocation of additional listeners.
#
# Section: Atom
#
# ## Example
#
# Here is a command that inserts the current date in an editor:
#
# ```coffee
# atom.commands.add 'atom-text-editor',
#   'user:insert-date': (event) ->
#     editor = @getModel()
#     editor.insertText(new Date().toLocaleString())
# ```
module.exports =
class CommandRegistry
  constructor: (@rootNode) ->
    @registeredCommands = {}
    @selectorBasedListenersByCommandName = {}
    @inlineListenersByCommandName = {}
    @emitter = new Emitter

  destroy: ->
    for commandName of @registeredCommands
      window.removeEventListener(commandName, @handleCommandEvent, true)

  # Public: Add one or more command listeners associated with a selector.
  #
  # ## Arguments: Registering One Command
  #
  # * `target` A {String} containing a CSS selector or a DOM element. If you
  #   pass a selector, the command will be globally associated with all matching
  #   elements. The `,` combinator is not currently supported. If you pass a
  #   DOM element, the command will be associated with just that element.
  # * `commandName` A {String} containing the name of a command you want to
  #   handle such as `user:insert-date`.
  # * `callback` A {Function} to call when the given command is invoked on an
  #   element matching the selector. It will be called with `this` referencing
  #   the matching DOM node.
  #   * `event` A standard DOM event instance. Call `stopPropagation` or
  #     `stopImmediatePropagation` to terminate bubbling early.
  #
  # ## Arguments: Registering Multiple Commands
  #
  # * `target` A {String} containing a CSS selector or a DOM element. If you
  #   pass a selector, the commands will be globally associated with all
  #   matching elements. The `,` combinator is not currently supported.
  #   If you pass a DOM element, the command will be associated with just that
  #   element.
  # * `commands` An {Object} mapping command names like `user:insert-date` to
  #   listener {Function}s.
  #
  # Returns a {Disposable} on which `.dispose()` can be called to remove the
  # added command handler(s).
  add: (target, commandName, callback) ->
    if typeof commandName is 'object'
      commands = commandName
      disposable = new CompositeDisposable
      for commandName, callback of commands
        disposable.add @add(target, commandName, callback)
      return disposable

    if not callback
      throw new Error("CommandRegistry:add called without a callback")

    if typeof target is 'string'
      @addSelectorBasedListener(target, commandName, callback)
    else
      @addInlineListener(target, commandName, callback)

  addSelectorBasedListener: (selector, commandName, callback) ->
    @selectorBasedListenersByCommandName[commandName] ?= []
    listenersForCommand = @selectorBasedListenersByCommandName[commandName]
    listener = new SelectorBasedListener(selector, callback)
    listenersForCommand.push(listener)

    @commandRegistered(commandName)

    new Disposable =>
      listenersForCommand.splice(listenersForCommand.indexOf(listener), 1)
      delete @selectorBasedListenersByCommandName[commandName] if listenersForCommand.length is 0

  addInlineListener: (element, commandName, callback) ->
    @inlineListenersByCommandName[commandName] ?= new WeakMap

    listenersForCommand = @inlineListenersByCommandName[commandName]
    unless listenersForElement = listenersForCommand.get(element)
      listenersForElement = []
      listenersForCommand.set(element, listenersForElement)
    listener = new InlineListener(callback)
    listenersForElement.push(listener)

    @commandRegistered(commandName)

    new Disposable ->
      listenersForElement.splice(listenersForElement.indexOf(listener), 1)
      listenersForCommand.delete(element) if listenersForElement.length is 0

  # Public: Find all registered commands matching a query.
  #
  # * `params` An {Object} containing one or more of the following keys:
  #   * `target` A DOM node that is the hypothetical target of a given command.
  #
  # Returns an {Array} of {Object}s containing the following keys:
  #  * `name` The name of the command. For example, `user:insert-date`.
  #  * `displayName` The display name of the command. For example,
  #    `User: Insert Date`.
  #  * `jQuery` Present if the command was registered with the legacy
  #    `$::command` method.
  findCommands: ({target}) ->
    commandNames = new Set
    commands = []
    currentTarget = target
    loop
      for name, listeners of @inlineListenersByCommandName
        if listeners.has(currentTarget) and not commandNames.has(name)
          commandNames.add(name)
          commands.push({name, displayName: _str.humanize(name)})

      for commandName, listeners of @selectorBasedListenersByCommandName
        for listener in listeners
          if currentTarget.webkitMatchesSelector?(listener.selector)
            unless commandNames.has(commandName)
              commandNames.add(commandName)
              commands.push
                name: commandName
                displayName: _str.humanize(commandName)

      break if currentTarget is window
      currentTarget = currentTarget.parentNode ? window

    commands

  # Public: Simulate the dispatch of a command on a DOM node.
  #
  # This can be useful for testing when you want to simulate the invocation of a
  # command on a detached DOM node. Otherwise, the DOM node in question needs to
  # be attached to the document so the event bubbles up to the root node to be
  # processed.
  #
  # * `target` The DOM node at which to start bubbling the command event.
  # * `commandName` {String} indicating the name of the command to dispatch.
  dispatch: (target, commandName, detail) ->
    event = new CustomEvent(commandName, {bubbles: true, detail})
    Object.defineProperty(event, 'target', {value: target})
    Object.defineProperty(event, 'preventDefault', {value: -> })
    Object.defineProperty(event, 'stopPropagation', {value: -> })
    Object.defineProperty(event, 'stopImmediatePropagation', {value: -> })
    @handleCommandEvent(event)

  onWillDispatch: (callback) ->
    @emitter.on 'will-dispatch', callback

  getSnapshot: ->
    snapshot = {}
    for commandName, listeners of @selectorBasedListenersByCommandName
      snapshot[commandName] = listeners.slice()
    snapshot

  restoreSnapshot: (snapshot) ->
    @selectorBasedListenersByCommandName = {}
    for commandName, listeners of snapshot
      @selectorBasedListenersByCommandName[commandName] = listeners.slice()

  handleCommandEvent: (originalEvent) =>
    propagationStopped = false
    immediatePropagationStopped = false
    currentTarget = originalEvent.target
    matched = false

    # We want to be able to simulate event bubbling and know when propogation
    # should stop. Redefine a few of the event's methods so that we can observe
    # them.
    _stopPropagation = originalEvent.stopPropagation
    _stopImmediatePropagation = originalEvent.stopImmediatePropagation
    _abortKeyBinding = originalEvent.abortKeyBinding

    eventHooks =
      eventPhase: value: Event.BUBBLING_PHASE
      currentTarget: get: -> currentTarget
      stopPropagation: value: ->
        propagationStopped = true
        _stopPropagation()
      stopImmediatePropagation: value: ->
        propagationStopped = true
        immediatePropagationStopped = true
        _stopImmediatePropagation()
      abortKeyBinding: value: ->
        originalEvent.abortKeyBinding?()

    # Note: There used to be a more elegant solution to this, but you can no longer
    # call Object.create with a CustomEvent as the object prototype.
    for prop, def in eventHooks
      Object.defineProperty(originalEvent, prop, def)
    syntheticEvent = originalEvent

    @emitter.emit 'will-dispatch', syntheticEvent

    loop
      listeners = @inlineListenersByCommandName[originalEvent.type]?.get(currentTarget) ? []
      if currentTarget.webkitMatchesSelector?
        selectorBasedListeners =
          (@selectorBasedListenersByCommandName[originalEvent.type] ? [])
            .filter (listener) -> currentTarget.webkitMatchesSelector(listener.selector)
            .sort (a, b) -> a.compare(b)
        listeners = listeners.concat(selectorBasedListeners)

      matched = true if listeners.length > 0

      for listener in listeners
        break if immediatePropagationStopped
        listener.callback.call(currentTarget, syntheticEvent)

      break if currentTarget is window
      break if propagationStopped
      currentTarget = currentTarget.parentNode ? window

    matched

  commandRegistered: (commandName) ->
    unless @registeredCommands[commandName]
      window.addEventListener(commandName, @handleCommandEvent, true)
      @registeredCommands[commandName] = true

class SelectorBasedListener
  constructor: (@selector, @callback) ->
    @specificity = (SpecificityCache[@selector] ?= specificity(@selector))
    @sequenceNumber = SequenceCount++

  compare: (other) ->
    other.specificity - @specificity  or
      other.sequenceNumber - @sequenceNumber

class InlineListener
  constructor: (@callback) ->
