'use strict'

const { Emitter, Disposable, CompositeDisposable } = require('event-kit')
const { calculateSpecificity, validateSelector } = require('clear-cut')
const _ = require('underscore-plus')

let SequenceCount = 0

// 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.
//
// Command names must follow the `namespace:action` pattern, where `namespace`
// will typically be the name of your package, and `action` describes the
// behavior of your command. If either part consists of multiple words, these
// must be separated by hyphens. E.g. `awesome-package:turn-it-up-to-eleven`.
// All words should be lowercased.
//
// 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.
//
// ## 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 () {
    this.handleCommandEvent = this.handleCommandEvent.bind(this)
    this.rootNode = null
    this.clear()
  }

  clear () {
    this.registeredCommands = {}
    this.selectorBasedListenersByCommandName = {}
    this.inlineListenersByCommandName = {}
    this.emitter = new Emitter()
  }

  attach (rootNode) {
    this.rootNode = rootNode
    for (const command in this.selectorBasedListenersByCommandName) {
      this.commandRegistered(command)
    }

    for (const command in this.inlineListenersByCommandName) {
      this.commandRegistered(command)
    }
  }

  destroy () {
    for (const commandName in this.registeredCommands) {
      this.rootNode.removeEventListener(
        commandName,
        this.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`.
  // * `listener` A listener which handles the event.  Either a {Function} to
  //   call when the given command is invoked on an element matching the
  //   selector, or an {Object} with a `didDispatch` property which is such a
  //   function.
  //
  //   The function (`listener` itself if it is a function, or the `didDispatch`
  //   method if `listener` is an object) will be called with `this` referencing
  //   the matching DOM node and the following argument:
  //     * `event`: A standard DOM event instance. Call `stopPropagation` or
  //       `stopImmediatePropagation` to terminate bubbling early.
  //
  //   Additionally, `listener` may have additional properties which are returned
  //   to those who query using `atom.commands.findCommands`, as well as several
  //   meaningful metadata properties:
  //     * `displayName`: Overrides any generated `displayName` that would
  //       otherwise be generated from the event name.
  //     * `description`: Used by consumers to display detailed information about
  //       the command.
  //     * `hiddenInCommandPalette`: If `true`, this command will not appear in
  //       the bundled command palette by default, but can still be shown with.
  //       the `Command Palette: Show Hidden Commands` command. This is a good
  //       option when you need to register large numbers of commands that don't
  //       make sense to be executed from the command palette. Please use this
  //       option conservatively, as it could reduce the discoverability of your
  //       package's commands.
  //
  // ## 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, listener, throwOnInvalidSelector = true) {
    if (typeof commandName === 'object') {
      const commands = commandName
      throwOnInvalidSelector = listener
      const disposable = new CompositeDisposable()
      for (commandName in commands) {
        listener = commands[commandName]
        disposable.add(this.add(target, commandName, listener, throwOnInvalidSelector))
      }
      return disposable
    }

    if (listener == null) {
      throw new Error('Cannot register a command with a null listener.')
    }

    // type Listener = ((e: CustomEvent) => void) | {
    //   displayName?: string,
    //   description?: string,
    //   didDispatch(e: CustomEvent): void,
    // }
    if ((typeof listener !== 'function') && (typeof listener.didDispatch !== 'function')) {
      throw new Error('Listener must be a callback function or an object with a didDispatch method.')
    }

    if (typeof target === 'string') {
      if (throwOnInvalidSelector) {
        validateSelector(target)
      }
      return this.addSelectorBasedListener(target, commandName, listener)
    } else {
      return this.addInlineListener(target, commandName, listener)
    }
  }

  addSelectorBasedListener (selector, commandName, listener) {
    if (this.selectorBasedListenersByCommandName[commandName] == null) {
      this.selectorBasedListenersByCommandName[commandName] = []
    }
    const listenersForCommand = this.selectorBasedListenersByCommandName[commandName]
    const selectorListener = new SelectorBasedListener(selector, commandName, listener)
    listenersForCommand.push(selectorListener)

    this.commandRegistered(commandName)

    return new Disposable(() => {
      listenersForCommand.splice(listenersForCommand.indexOf(selectorListener), 1)
      if (listenersForCommand.length === 0) {
        delete this.selectorBasedListenersByCommandName[commandName]
      }
    })
  }

  addInlineListener (element, commandName, listener) {
    if (this.inlineListenersByCommandName[commandName] == null) {
      this.inlineListenersByCommandName[commandName] = new WeakMap()
    }

    const listenersForCommand = this.inlineListenersByCommandName[commandName]
    let listenersForElement = listenersForCommand.get(element)
    if (!listenersForElement) {
      listenersForElement = []
      listenersForCommand.set(element, listenersForElement)
    }
    const inlineListener = new InlineListener(commandName, listener)
    listenersForElement.push(inlineListener)

    this.commandRegistered(commandName)

    return new Disposable(() => {
      listenersForElement.splice(listenersForElement.indexOf(inlineListener), 1)
      if (listenersForElement.length === 0) {
        listenersForCommand.delete(element)
      }
    })
  }

  // 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 `CommandDescriptor` {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`.
  // Additional metadata may also be present in the returned descriptor:
  //  * `description` a {String} describing the function of the command in more
  //    detail than the title
  //  * `tags` an {Array} of {String}s that describe keywords related to the
  //    command
  //  Any additional nonstandard metadata provided when the command was `add`ed
  //  may also be present in the returned descriptor.
  findCommands ({ target }) {
    const commandNames = new Set()
    const commands = []
    let currentTarget = target
    while (true) {
      let listeners
      for (const name in this.inlineListenersByCommandName) {
        listeners = this.inlineListenersByCommandName[name]
        if (listeners.has(currentTarget) && !commandNames.has(name)) {
          commandNames.add(name)
          const targetListeners = listeners.get(currentTarget)
          commands.push(
            ...targetListeners.map(listener => listener.descriptor)
          )
        }
      }

      for (const commandName in this.selectorBasedListenersByCommandName) {
        listeners = this.selectorBasedListenersByCommandName[commandName]
        for (const listener of listeners) {
          if (listener.matchesTarget(currentTarget)) {
            if (!commandNames.has(commandName)) {
              commandNames.add(commandName)
              commands.push(listener.descriptor)
            }
          }
        }
      }

      if (currentTarget === window) {
        break
      }
      currentTarget = currentTarget.parentNode || window
    }

    return 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) {
    const event = new CustomEvent(commandName, { bubbles: true, detail })
    Object.defineProperty(event, 'target', { value: target })
    return this.handleCommandEvent(event)
  }

  // Public: Invoke the given callback before dispatching a command event.
  //
  // * `callback` {Function} to be called before dispatching each command
  //   * `event` The Event that will be dispatched
  onWillDispatch (callback) {
    return this.emitter.on('will-dispatch', callback)
  }

  // Public: Invoke the given callback after dispatching a command event.
  //
  // * `callback` {Function} to be called after dispatching each command
  //   * `event` The Event that was dispatched
  onDidDispatch (callback) {
    return this.emitter.on('did-dispatch', callback)
  }

  getSnapshot () {
    const snapshot = {}
    for (const commandName in this.selectorBasedListenersByCommandName) {
      const listeners = this.selectorBasedListenersByCommandName[commandName]
      snapshot[commandName] = listeners.slice()
    }
    return snapshot
  }

  restoreSnapshot (snapshot) {
    this.selectorBasedListenersByCommandName = {}
    for (const commandName in snapshot) {
      const listeners = snapshot[commandName]
      this.selectorBasedListenersByCommandName[commandName] = listeners.slice()
    }
  }

  handleCommandEvent (event) {
    let propagationStopped = false
    let immediatePropagationStopped = false
    let matched = false
    let currentTarget = event.target

    const dispatchedEvent = new CustomEvent(event.type, {
      bubbles: true,
      detail: event.detail
    })
    Object.defineProperty(dispatchedEvent, 'eventPhase', {
      value: Event.BUBBLING_PHASE
    })
    Object.defineProperty(dispatchedEvent, 'currentTarget', {
      get () {
        return currentTarget
      }
    })
    Object.defineProperty(dispatchedEvent, 'target', { value: currentTarget })
    Object.defineProperty(dispatchedEvent, 'preventDefault', {
      value () {
        return event.preventDefault()
      }
    })
    Object.defineProperty(dispatchedEvent, 'stopPropagation', {
      value () {
        event.stopPropagation()
        propagationStopped = true
      }
    })
    Object.defineProperty(dispatchedEvent, 'stopImmediatePropagation', {
      value () {
        event.stopImmediatePropagation()
        propagationStopped = true
        immediatePropagationStopped = true
      }
    })
    Object.defineProperty(dispatchedEvent, 'abortKeyBinding', {
      value () {
        if (typeof event.abortKeyBinding === 'function') {
          event.abortKeyBinding()
        }
      }
    })

    for (const key of Object.keys(event)) {
      if (!(key in dispatchedEvent)) {
        dispatchedEvent[key] = event[key]
      }
    }

    this.emitter.emit('will-dispatch', dispatchedEvent)

    while (true) {
      const commandInlineListeners =
        this.inlineListenersByCommandName[event.type]
        ? this.inlineListenersByCommandName[event.type].get(currentTarget)
        : null
      let listeners = commandInlineListeners || []
      if (currentTarget.webkitMatchesSelector != null) {
        const selectorBasedListeners =
          (this.selectorBasedListenersByCommandName[event.type] || [])
            .filter(listener => listener.matchesTarget(currentTarget))
            .sort((a, b) => a.compare(b))
        listeners = selectorBasedListeners.concat(listeners)
      }

      if (listeners.length > 0) {
        matched = true
      }

      // Call inline listeners first in reverse registration order,
      // and selector-based listeners by specificity and reverse
      // registration order.
      for (let i = listeners.length - 1; i >= 0; i--) {
        const listener = listeners[i]
        if (immediatePropagationStopped) {
          break
        }
        listener.didDispatch.call(currentTarget, dispatchedEvent)
      }

      if (currentTarget === window) {
        break
      }
      if (propagationStopped) {
        break
      }
      currentTarget = currentTarget.parentNode || window
    }

    this.emitter.emit('did-dispatch', dispatchedEvent)

    return matched
  }

  commandRegistered (commandName) {
    if (this.rootNode != null && !this.registeredCommands[commandName]) {
      this.rootNode.addEventListener(commandName, this.handleCommandEvent, true)
      return (this.registeredCommands[commandName] = true)
    }
  }
}

// type Listener = {
//   descriptor: CommandDescriptor,
//   extractDidDispatch: (e: CustomEvent) => void,
// };
class SelectorBasedListener {
  constructor (selector, commandName, listener) {
    this.selector = selector
    this.didDispatch = extractDidDispatch(listener)
    this.descriptor = extractDescriptor(commandName, listener)
    this.specificity = calculateSpecificity(this.selector)
    this.sequenceNumber = SequenceCount++
  }

  compare (other) {
    return (
      this.specificity - other.specificity ||
      this.sequenceNumber - other.sequenceNumber
    )
  }

  matchesTarget (target) {
    return target.webkitMatchesSelector && target.webkitMatchesSelector(this.selector)
  }
}

class InlineListener {
  constructor (commandName, listener) {
    this.didDispatch = extractDidDispatch(listener)
    this.descriptor = extractDescriptor(commandName, listener)
  }
}

// type CommandDescriptor = {
//   name: string,
//   displayName: string,
// };
function extractDescriptor (name, listener) {
  return Object.assign(
    _.omit(listener, 'didDispatch'),
    {
      name,
      displayName: listener.displayName ? listener.displayName : _.humanizeEventName(name)
    }
  )
}

function extractDidDispatch (listener) {
  return typeof listener === 'function' ? listener : listener.didDispatch
}
