const Grim = require('grim')
const {CompositeDisposable, Emitter} = require('event-kit')
const PaneAxis = require('./pane-axis')
const TextEditor = require('./text-editor')
const PaneElement = require('./pane-element')

let nextInstanceId = 1

class SaveCancelledError extends Error {}

// Extended: A container for presenting content in the center of the workspace.
// Panes can contain multiple items, one of which is *active* at a given time.
// The view corresponding to the active item is displayed in the interface. In
// the default configuration, tabs are also displayed for each item.
//
// Each pane may also contain one *pending* item. When a pending item is added
// to a pane, it will replace the currently pending item, if any, instead of
// simply being added. In the default configuration, the text in the tab for
// pending items is shown in italics.
module.exports =
class Pane {
  inspect () {
    return `Pane ${this.id}`
  }

  static deserialize (state, {deserializers, applicationDelegate, config, notifications, views}) {
    const {activeItemIndex} = state
    const activeItemURI = state.activeItemURI || state.activeItemUri

    const items = []
    for (const itemState of state.items) {
      const item = deserializers.deserialize(itemState)
      if (item) items.push(item)
    }
    state.items = items

    state.activeItem = items[activeItemIndex]
    if (!state.activeItem && activeItemURI) {
      state.activeItem = state.items.find((item) =>
        typeof item.getURI === 'function' && item.getURI() === activeItemURI
      )
    }

    return new Pane(Object.assign(state, {
      deserializerManager: deserializers,
      notificationManager: notifications,
      viewRegistry: views,
      config,
      applicationDelegate
    }))
  }

  constructor (params = {}) {
    this.setPendingItem = this.setPendingItem.bind(this)
    this.getPendingItem = this.getPendingItem.bind(this)
    this.clearPendingItem = this.clearPendingItem.bind(this)
    this.onItemDidTerminatePendingState = this.onItemDidTerminatePendingState.bind(this)
    this.saveItem = this.saveItem.bind(this)
    this.saveItemAs = this.saveItemAs.bind(this)

    this.id = params.id
    if (this.id != null) {
      nextInstanceId = Math.max(nextInstanceId, this.id + 1)
    } else {
      this.id = nextInstanceId++
    }

    this.activeItem = params.activeItem
    this.focused = params.focused != null ? params.focused : false
    this.applicationDelegate = params.applicationDelegate
    this.notificationManager = params.notificationManager
    this.config = params.config
    this.deserializerManager = params.deserializerManager
    this.viewRegistry = params.viewRegistry

    this.emitter = new Emitter()
    this.alive = true
    this.subscriptionsPerItem = new WeakMap()
    this.items = []
    this.itemStack = []
    this.container = null

    this.addItems((params.items || []).filter(item => item))
    if (!this.getActiveItem()) this.setActiveItem(this.items[0])
    this.addItemsToStack(params.itemStackIndices || [])
    this.setFlexScale(params.flexScale || 1)
  }

  getElement () {
    if (!this.element) {
      this.element = new PaneElement().initialize(
        this,
        {views: this.viewRegistry, applicationDelegate: this.applicationDelegate}
      )
    }
    return this.element
  }

  serialize () {
    const itemsToBeSerialized = this.items.filter(item => item && typeof item.serialize === 'function')

    const itemStackIndices = []
    for (const item of this.itemStack) {
      if (typeof item.serialize === 'function') {
        itemStackIndices.push(itemsToBeSerialized.indexOf(item))
      }
    }

    const activeItemIndex = itemsToBeSerialized.indexOf(this.activeItem)

    return {
      deserializer: 'Pane',
      id: this.id,
      items: itemsToBeSerialized.map(item => item.serialize()),
      itemStackIndices,
      activeItemIndex,
      focused: this.focused,
      flexScale: this.flexScale
    }
  }

  getParent () { return this.parent }

  setParent (parent) {
    this.parent = parent
  }

  getContainer () { return this.container }

  setContainer (container) {
    if (container && container !== this.container) {
      this.container = container
      container.didAddPane({pane: this})
    }
  }

  // Private: Determine whether the given item is allowed to exist in this pane.
  //
  // * `item` the Item
  //
  // Returns a {Boolean}.
  isItemAllowed (item) {
    if (typeof item.getAllowedLocations !== 'function') {
      return true
    } else {
      return item.getAllowedLocations().includes(this.getContainer().getLocation())
    }
  }

  setFlexScale (flexScale) {
    this.flexScale = flexScale
    this.emitter.emit('did-change-flex-scale', this.flexScale)
    return this.flexScale
  }

  getFlexScale () { return this.flexScale }

  increaseSize () {
    if (this.getContainer().getPanes().length > 1) {
      this.setFlexScale(this.getFlexScale() * 1.1)
    }
  }

  decreaseSize () {
    if (this.getContainer().getPanes().length > 1) {
      this.setFlexScale(this.getFlexScale() / 1.1)
    }
  }

  /*
  Section: Event Subscription
  */

  // Public: Invoke the given callback when the pane resizes
  //
  // The callback will be invoked when pane's flexScale property changes.
  // Use {::getFlexScale} to get the current value.
  //
  // * `callback` {Function} to be called when the pane is resized
  //   * `flexScale` {Number} representing the panes `flex-grow`; ability for a
  //     flex item to grow if necessary.
  //
  // Returns a {Disposable} on which '.dispose()' can be called to unsubscribe.
  onDidChangeFlexScale (callback) {
    return this.emitter.on('did-change-flex-scale', callback)
  }

  // Public: Invoke the given callback with the current and future values of
  // {::getFlexScale}.
  //
  // * `callback` {Function} to be called with the current and future values of
  //   the {::getFlexScale} property.
  //   * `flexScale` {Number} representing the panes `flex-grow`; ability for a
  //     flex item to grow if necessary.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeFlexScale (callback) {
    callback(this.flexScale)
    return this.onDidChangeFlexScale(callback)
  }

  // Public: Invoke the given callback when the pane is activated.
  //
  // The given callback will be invoked whenever {::activate} is called on the
  // pane, even if it is already active at the time.
  //
  // * `callback` {Function} to be called when the pane is activated.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidActivate (callback) {
    return this.emitter.on('did-activate', callback)
  }

  // Public: Invoke the given callback before the pane is destroyed.
  //
  // * `callback` {Function} to be called before the pane is destroyed.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onWillDestroy (callback) {
    return this.emitter.on('will-destroy', callback)
  }

  // Public: Invoke the given callback when the pane is destroyed.
  //
  // * `callback` {Function} to be called when the pane is destroyed.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidDestroy (callback) {
    return this.emitter.once('did-destroy', callback)
  }

  // Public: Invoke the given callback when the value of the {::isActive}
  // property changes.
  //
  // * `callback` {Function} to be called when the value of the {::isActive}
  //   property changes.
  //   * `active` {Boolean} indicating whether the pane is active.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeActive (callback) {
    return this.container.onDidChangeActivePane(activePane => {
      const isActive = this === activePane
      callback(isActive)
    })
  }

  // Public: Invoke the given callback with the current and future values of the
  // {::isActive} property.
  //
  // * `callback` {Function} to be called with the current and future values of
  //   the {::isActive} property.
  //   * `active` {Boolean} indicating whether the pane is active.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeActive (callback) {
    callback(this.isActive())
    return this.onDidChangeActive(callback)
  }

  // Public: Invoke the given callback when an item is added to the pane.
  //
  // * `callback` {Function} to be called with when items are added.
  //   * `event` {Object} with the following keys:
  //     * `item` The added pane item.
  //     * `index` {Number} indicating where the item is located.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidAddItem (callback) {
    return this.emitter.on('did-add-item', callback)
  }

  // Public: Invoke the given callback when an item is removed from the pane.
  //
  // * `callback` {Function} to be called with when items are removed.
  //   * `event` {Object} with the following keys:
  //     * `item` The removed pane item.
  //     * `index` {Number} indicating where the item was located.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidRemoveItem (callback) {
    return this.emitter.on('did-remove-item', callback)
  }

  // Public: Invoke the given callback before an item is removed from the pane.
  //
  // * `callback` {Function} to be called with when items are removed.
  //   * `event` {Object} with the following keys:
  //     * `item` The pane item to be removed.
  //     * `index` {Number} indicating where the item is located.
  onWillRemoveItem (callback) {
    return this.emitter.on('will-remove-item', callback)
  }

  // Public: Invoke the given callback when an item is moved within the pane.
  //
  // * `callback` {Function} to be called with when items are moved.
  //   * `event` {Object} with the following keys:
  //     * `item` The removed pane item.
  //     * `oldIndex` {Number} indicating where the item was located.
  //     * `newIndex` {Number} indicating where the item is now located.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidMoveItem (callback) {
    return this.emitter.on('did-move-item', callback)
  }

  // Public: Invoke the given callback with all current and future items.
  //
  // * `callback` {Function} to be called with current and future items.
  //   * `item` An item that is present in {::getItems} at the time of
  //     subscription or that is added at some later time.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeItems (callback) {
    for (let item of this.getItems()) {
      callback(item)
    }
    return this.onDidAddItem(({item}) => callback(item))
  }

  // Public: Invoke the given callback when the value of {::getActiveItem}
  // changes.
  //
  // * `callback` {Function} to be called with when the active item changes.
  //   * `activeItem` The current active item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDidChangeActiveItem (callback) {
    return this.emitter.on('did-change-active-item', callback)
  }

  // Public: Invoke the given callback when {::activateNextRecentlyUsedItem}
  // has been called, either initiating or continuing a forward MRU traversal of
  // pane items.
  //
  // * `callback` {Function} to be called with when the active item changes.
  //   * `nextRecentlyUsedItem` The next MRU item, now being set active
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onChooseNextMRUItem (callback) {
    return this.emitter.on('choose-next-mru-item', callback)
  }

  // Public: Invoke the given callback when {::activatePreviousRecentlyUsedItem}
  // has been called, either initiating or continuing a reverse MRU traversal of
  // pane items.
  //
  // * `callback` {Function} to be called with when the active item changes.
  //   * `previousRecentlyUsedItem` The previous MRU item, now being set active
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onChooseLastMRUItem (callback) {
    return this.emitter.on('choose-last-mru-item', callback)
  }

  // Public: Invoke the given callback when {::moveActiveItemToTopOfStack}
  // has been called, terminating an MRU traversal of pane items and moving the
  // current active item to the top of the stack. Typically bound to a modifier
  // (e.g. CTRL) key up event.
  //
  // * `callback` {Function} to be called with when the MRU traversal is done.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  onDoneChoosingMRUItem (callback) {
    return this.emitter.on('done-choosing-mru-item', callback)
  }

  // Public: Invoke the given callback with the current and future values of
  // {::getActiveItem}.
  //
  // * `callback` {Function} to be called with the current and future active
  //   items.
  //   * `activeItem` The current active item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to unsubscribe.
  observeActiveItem (callback) {
    callback(this.getActiveItem())
    return this.onDidChangeActiveItem(callback)
  }

  // Public: Invoke the given callback before items are destroyed.
  //
  // * `callback` {Function} to be called before items are destroyed.
  //   * `event` {Object} with the following keys:
  //     * `item` The item that will be destroyed.
  //     * `index` The location of the item.
  //
  // Returns a {Disposable} on which `.dispose()` can be called to
  // unsubscribe.
  onWillDestroyItem (callback) {
    return this.emitter.on('will-destroy-item', callback)
  }

  // Called by the view layer to indicate that the pane has gained focus.
  focus () {
    this.focused = true
    return this.activate()
  }

  // Called by the view layer to indicate that the pane has lost focus.
  blur () {
    this.focused = false
    return true // if this is called from an event handler, don't cancel it
  }

  isFocused () { return this.focused }

  getPanes () { return [this] }

  unsubscribeFromItem (item) {
    const subscription = this.subscriptionsPerItem.get(item)
    if (subscription) {
      subscription.dispose()
      this.subscriptionsPerItem.delete(item)
    }
  }

  /*
  Section: Items
  */

  // Public: Get the items in this pane.
  //
  // Returns an {Array} of items.
  getItems () {
    return this.items.slice()
  }

  // Public: Get the active pane item in this pane.
  //
  // Returns a pane item.
  getActiveItem () { return this.activeItem }

  setActiveItem (activeItem, options) {
    const modifyStack = options && options.modifyStack
    if (activeItem !== this.activeItem) {
      if (modifyStack !== false) this.addItemToStack(activeItem)
      this.activeItem = activeItem
      this.emitter.emit('did-change-active-item', this.activeItem)
      if (this.container) this.container.didChangeActiveItemOnPane(this, this.activeItem)
    }
    return this.activeItem
  }

  // Build the itemStack after deserializing
  addItemsToStack (itemStackIndices) {
    if (this.items.length > 0) {
      if (itemStackIndices.length !== this.items.length || itemStackIndices.includes(-1)) {
        itemStackIndices = this.items.map((item, i) => i)
      }

      for (let itemIndex of itemStackIndices) {
        this.addItemToStack(this.items[itemIndex])
      }
    }
  }

  // Add item (or move item) to the end of the itemStack
  addItemToStack (newItem) {
    if (newItem == null) { return }
    const index = this.itemStack.indexOf(newItem)
    if (index !== -1) this.itemStack.splice(index, 1)
    return this.itemStack.push(newItem)
  }

  // Return an {TextEditor} if the pane item is an {TextEditor}, or null otherwise.
  getActiveEditor () {
    if (this.activeItem instanceof TextEditor) return this.activeItem
  }

  // Public: Return the item at the given index.
  //
  // * `index` {Number}
  //
  // Returns an item or `null` if no item exists at the given index.
  itemAtIndex (index) {
    return this.items[index]
  }

  // Makes the next item in the itemStack active.
  activateNextRecentlyUsedItem () {
    if (this.items.length > 1) {
      if (this.itemStackIndex == null) this.itemStackIndex = this.itemStack.length - 1
      if (this.itemStackIndex === 0) this.itemStackIndex = this.itemStack.length
      this.itemStackIndex--
      const nextRecentlyUsedItem = this.itemStack[this.itemStackIndex]
      this.emitter.emit('choose-next-mru-item', nextRecentlyUsedItem)
      this.setActiveItem(nextRecentlyUsedItem, {modifyStack: false})
    }
  }

  // Makes the previous item in the itemStack active.
  activatePreviousRecentlyUsedItem () {
    if (this.items.length > 1) {
      if (this.itemStackIndex + 1 === this.itemStack.length || this.itemStackIndex == null) {
        this.itemStackIndex = -1
      }
      this.itemStackIndex++
      const previousRecentlyUsedItem = this.itemStack[this.itemStackIndex]
      this.emitter.emit('choose-last-mru-item', previousRecentlyUsedItem)
      this.setActiveItem(previousRecentlyUsedItem, {modifyStack: false})
    }
  }

  // Moves the active item to the end of the itemStack once the ctrl key is lifted
  moveActiveItemToTopOfStack () {
    delete this.itemStackIndex
    this.addItemToStack(this.activeItem)
    this.emitter.emit('done-choosing-mru-item')
  }

  // Public: Makes the next item active.
  activateNextItem () {
    const index = this.getActiveItemIndex()
    if (index < (this.items.length - 1)) {
      this.activateItemAtIndex(index + 1)
    } else {
      this.activateItemAtIndex(0)
    }
  }

  // Public: Makes the previous item active.
  activatePreviousItem () {
    const index = this.getActiveItemIndex()
    if (index > 0) {
      this.activateItemAtIndex(index - 1)
    } else {
      this.activateItemAtIndex(this.items.length - 1)
    }
  }

  activateLastItem () {
    this.activateItemAtIndex(this.items.length - 1)
  }

  // Public: Move the active tab to the right.
  moveItemRight () {
    const index = this.getActiveItemIndex()
    const rightItemIndex = index + 1
    if (rightItemIndex <= this.items.length - 1) this.moveItem(this.getActiveItem(), rightItemIndex)
  }

  // Public: Move the active tab to the left
  moveItemLeft () {
    const index = this.getActiveItemIndex()
    const leftItemIndex = index - 1
    if (leftItemIndex >= 0) return this.moveItem(this.getActiveItem(), leftItemIndex)
  }

  // Public: Get the index of the active item.
  //
  // Returns a {Number}.
  getActiveItemIndex () {
    return this.items.indexOf(this.activeItem)
  }

  // Public: Activate the item at the given index.
  //
  // * `index` {Number}
  activateItemAtIndex (index) {
    const item = this.itemAtIndex(index) || this.getActiveItem()
    return this.setActiveItem(item)
  }

  // Public: Make the given item *active*, causing it to be displayed by
  // the pane's view.
  //
  // * `item` The item to activate
  // * `options` (optional) {Object}
  //   * `pending` (optional) {Boolean} indicating that the item should be added
  //     in a pending state if it does not yet exist in the pane. Existing pending
  //     items in a pane are replaced with new pending items when they are opened.
  activateItem (item, options = {}) {
    if (item) {
      const index = (this.getPendingItem() === this.activeItem)
        ? this.getActiveItemIndex()
        : this.getActiveItemIndex() + 1
      this.addItem(item, Object.assign({}, options, {index}))
      this.setActiveItem(item)
    }
  }

  // Public: Add the given item to the pane.
  //
  // * `item` The item to add. It can be a model with an associated view or a
  //   view.
  // * `options` (optional) {Object}
  //   * `index` (optional) {Number} indicating the index at which to add the item.
  //     If omitted, the item is added after the current active item.
  //   * `pending` (optional) {Boolean} indicating that the item should be
  //     added in a pending state. Existing pending items in a pane are replaced with
  //     new pending items when they are opened.
  //
  // Returns the added item.
  addItem (item, options = {}) {
    // Backward compat with old API:
    //   addItem(item, index=@getActiveItemIndex() + 1)
    if (typeof options === 'number') {
      Grim.deprecate(`Pane::addItem(item, ${options}) is deprecated in favor of Pane::addItem(item, {index: ${options}})`)
      options = {index: options}
    }

    const index = options.index != null ? options.index : this.getActiveItemIndex() + 1
    const moved = options.moved != null ? options.moved : false
    const pending = options.pending != null ? options.pending : false

    if (!item || typeof item !== 'object') {
      throw new Error(`Pane items must be objects. Attempted to add item ${item}.`)
    }

    if (typeof item.isDestroyed === 'function' && item.isDestroyed()) {
      throw new Error(`Adding a pane item with URI '${typeof item.getURI === 'function' && item.getURI()}' that has already been destroyed`)
    }

    if (this.items.includes(item)) return

    if (typeof item.onDidDestroy === 'function') {
      const itemSubscriptions = new CompositeDisposable()
      itemSubscriptions.add(item.onDidDestroy(() => this.removeItem(item, false)))
      if (typeof item.onDidTerminatePendingState === 'function') {
        itemSubscriptions.add(item.onDidTerminatePendingState(() => {
          if (this.getPendingItem() === item) this.clearPendingItem()
        }))
      }
      this.subscriptionsPerItem.set(item, itemSubscriptions)
    }

    this.items.splice(index, 0, item)
    const lastPendingItem = this.getPendingItem()
    const replacingPendingItem = lastPendingItem != null && !moved
    if (replacingPendingItem) this.pendingItem = null
    if (pending) this.setPendingItem(item)

    this.emitter.emit('did-add-item', {item, index, moved})
    if (!moved) {
      if (this.container) this.container.didAddPaneItem(item, this, index)
    }

    if (replacingPendingItem) this.destroyItem(lastPendingItem)
    if (!this.getActiveItem()) this.setActiveItem(item)
    return item
  }

  setPendingItem (item) {
    if (this.pendingItem !== item) {
      const mostRecentPendingItem = this.pendingItem
      this.pendingItem = item
      if (mostRecentPendingItem) {
        this.emitter.emit('item-did-terminate-pending-state', mostRecentPendingItem)
      }
    }
  }

  getPendingItem () {
    return this.pendingItem || null
  }

  clearPendingItem () {
    this.setPendingItem(null)
  }

  onItemDidTerminatePendingState (callback) {
    return this.emitter.on('item-did-terminate-pending-state', callback)
  }

  // Public: Add the given items to the pane.
  //
  // * `items` An {Array} of items to add. Items can be views or models with
  //   associated views. Any objects that are already present in the pane's
  //   current items will not be added again.
  // * `index` (optional) {Number} index at which to add the items. If omitted,
  //   the item is #   added after the current active item.
  //
  // Returns an {Array} of added items.
  addItems (items, index = this.getActiveItemIndex() + 1) {
    items = items.filter(item => !this.items.includes(item))
    for (let i = 0; i < items.length; i++) {
      const item = items[i]
      this.addItem(item, {index: index + i})
    }
    return items
  }

  removeItem (item, moved) {
    const index = this.items.indexOf(item)
    if (index === -1) return
    if (this.getPendingItem() === item) this.pendingItem = null
    this.removeItemFromStack(item)
    this.emitter.emit('will-remove-item', {item, index, destroyed: !moved, moved})
    this.unsubscribeFromItem(item)

    if (item === this.activeItem) {
      if (this.items.length === 1) {
        this.setActiveItem(undefined)
      } else if (index === 0) {
        this.activateNextItem()
      } else {
        this.activatePreviousItem()
      }
    }
    this.items.splice(index, 1)
    this.emitter.emit('did-remove-item', {item, index, destroyed: !moved, moved})
    if (!moved && this.container) this.container.didDestroyPaneItem({item, index, pane: this})
    if (this.items.length === 0 && this.config.get('core.destroyEmptyPanes')) this.destroy()
  }

  // Remove the given item from the itemStack.
  //
  // * `item` The item to remove.
  // * `index` {Number} indicating the index to which to remove the item from the itemStack.
  removeItemFromStack (item) {
    const index = this.itemStack.indexOf(item)
    if (index !== -1) this.itemStack.splice(index, 1)
  }

  // Public: Move the given item to the given index.
  //
  // * `item` The item to move.
  // * `index` {Number} indicating the index to which to move the item.
  moveItem (item, newIndex) {
    const oldIndex = this.items.indexOf(item)
    this.items.splice(oldIndex, 1)
    this.items.splice(newIndex, 0, item)
    this.emitter.emit('did-move-item', {item, oldIndex, newIndex})
  }

  // Public: Move the given item to the given index on another pane.
  //
  // * `item` The item to move.
  // * `pane` {Pane} to which to move the item.
  // * `index` {Number} indicating the index to which to move the item in the
  //   given pane.
  moveItemToPane (item, pane, index) {
    this.removeItem(item, true)
    return pane.addItem(item, {index, moved: true})
  }

  // Public: Destroy the active item and activate the next item.
  //
  // Returns a {Promise} that resolves when the item is destroyed.
  destroyActiveItem () {
    return this.destroyItem(this.activeItem)
  }

  // Public: Destroy the given item.
  //
  // If the item is active, the next item will be activated. If the item is the
  // last item, the pane will be destroyed if the `core.destroyEmptyPanes` config
  // setting is `true`.
  //
  // * `item` Item to destroy
  // * `force` (optional) {Boolean} Destroy the item without prompting to save
  //    it, even if the item's `isPermanentDockItem` method returns true.
  //
  // Returns a {Promise} that resolves with a {Boolean} indicating whether or not
  // the item was destroyed.
  async destroyItem (item, force) {
    const index = this.items.indexOf(item)
    if (index === -1) return false

    if (!force &&
        typeof item.isPermanentDockItem === 'function' && item.isPermanentDockItem() &&
        (!this.container || this.container.getLocation() !== 'center')) {
      return false
    }

    // In the case where there are no `onWillDestroyPaneItem` listeners, preserve the old behavior
    // where `Pane.destroyItem` and callers such as `Pane.close` take effect synchronously.
    if (this.emitter.listenerCountForEventName('will-destroy-item') > 0) {
      await this.emitter.emitAsync('will-destroy-item', {item, index})
    }
    if (this.container && this.container.emitter.listenerCountForEventName('will-destroy-pane-item') > 0) {
      await this.container.willDestroyPaneItem({item, index, pane: this})
    }

    if (!force && typeof item.shouldPromptToSave === 'function' && item.shouldPromptToSave()) {
      if (!await this.promptToSaveItem(item)) return false
    }
    this.removeItem(item, false)
    if (typeof item.destroy === 'function') item.destroy()
    return true
  }

  // Public: Destroy all items.
  destroyItems () {
    return Promise.all(
      this.getItems().map(item => this.destroyItem(item))
    )
  }

  // Public: Destroy all items except for the active item.
  destroyInactiveItems () {
    return Promise.all(
      this.getItems()
        .filter(item => item !== this.activeItem)
        .map(item => this.destroyItem(item))
    )
  }

  promptToSaveItem (item, options = {}) {
    return new Promise((resolve, reject) => {
      if (typeof item.shouldPromptToSave !== 'function' || !item.shouldPromptToSave(options)) {
        return resolve(true)
      }

      let uri
      if (typeof item.getURI === 'function') {
        uri = item.getURI()
      } else if (typeof item.getUri === 'function') {
        uri = item.getUri()
      } else {
        return resolve(true)
      }

      const title = (typeof item.getTitle === 'function' && item.getTitle()) || uri

      const saveDialog = (saveButtonText, saveFn, message) => {
        this.applicationDelegate.confirm({
          message,
          detail: 'Your changes will be lost if you close this item without saving.',
          buttons: [saveButtonText, 'Cancel', "&Don't Save"]
        }, response => {
          switch (response) {
            case 0:
              return saveFn(item, error => {
                if (error instanceof SaveCancelledError) {
                  resolve(false)
                } else if (error) {
                  saveDialog(
                    'Save as',
                    this.saveItemAs,
                    `'${title}' could not be saved.\nError: ${this.getMessageForErrorCode(error.code)}`
                  )
                } else {
                  resolve(true)
                }
              })
            case 1:
              return resolve(false)
            case 2:
              return resolve(true)
          }
        })
      }

      saveDialog('Save', this.saveItem, `'${title}' has changes, do you want to save them?`)
    })
  }

  // Public: Save the active item.
  saveActiveItem (nextAction) {
    return this.saveItem(this.getActiveItem(), nextAction)
  }

  // Public: Prompt the user for a location and save the active item with the
  // path they select.
  //
  // * `nextAction` (optional) {Function} which will be called after the item is
  //   successfully saved.
  //
  // Returns a {Promise} that resolves when the save is complete
  saveActiveItemAs (nextAction) {
    return this.saveItemAs(this.getActiveItem(), nextAction)
  }

  // Public: Save the given item.
  //
  // * `item` The item to save.
  // * `nextAction` (optional) {Function} which will be called with no argument
  //   after the item is successfully saved, or with the error if it failed.
  //   The return value will be that of `nextAction` or `undefined` if it was not
  //   provided
  //
  // Returns a {Promise} that resolves when the save is complete
  saveItem (item, nextAction) {
    if (!item) return Promise.resolve()

    let itemURI
    if (typeof item.getURI === 'function') {
      itemURI = item.getURI()
    } else if (typeof item.getUri === 'function') {
      itemURI = item.getUri()
    }

    if (itemURI != null) {
      if (typeof item.save === 'function') {
        return promisify(() => item.save())
          .then(() => {
            if (nextAction) nextAction()
          })
          .catch(error => {
            if (nextAction) {
              nextAction(error)
            } else {
              this.handleSaveError(error, item)
            }
          })
      } else if (nextAction) {
        nextAction()
        return Promise.resolve()
      }
    } else {
      return this.saveItemAs(item, nextAction)
    }
  }

  // Public: Prompt the user for a location and save the active item with the
  // path they select.
  //
  // * `item` The item to save.
  // * `nextAction` (optional) {Function} which will be called with no argument
  //   after the item is successfully saved, or with the error if it failed.
  //   The return value will be that of `nextAction` or `undefined` if it was not
  //   provided
  async saveItemAs (item, nextAction) {
    if (!item) return
    if (typeof item.saveAs !== 'function') return

    const saveOptions = typeof item.getSaveDialogOptions === 'function'
      ? item.getSaveDialogOptions()
      : {}

    const itemPath = item.getPath()
    if (itemPath && !saveOptions.defaultPath) saveOptions.defaultPath = itemPath

    let resolveSaveDialogPromise = null
    const saveDialogPromise = new Promise(resolve => { resolveSaveDialogPromise = resolve })
    this.applicationDelegate.showSaveDialog(saveOptions, newItemPath => {
      if (newItemPath) {
        promisify(() => item.saveAs(newItemPath))
          .then(() => {
            if (nextAction) {
              resolveSaveDialogPromise(nextAction())
            } else {
              resolveSaveDialogPromise()
            }
          })
          .catch(error => {
            if (nextAction) {
              resolveSaveDialogPromise(nextAction(error))
            } else {
              this.handleSaveError(error, item)
              resolveSaveDialogPromise()
            }
          })
      } else if (nextAction) {
        resolveSaveDialogPromise(nextAction(new SaveCancelledError('Save Cancelled')))
      } else {
        resolveSaveDialogPromise()
      }
    })

    return await saveDialogPromise
  }

  // Public: Save all items.
  saveItems () {
    for (let item of this.getItems()) {
      if (typeof item.isModified === 'function' && item.isModified()) {
        this.saveItem(item)
      }
    }
  }

  // Public: Return the first item that matches the given URI or undefined if
  // none exists.
  //
  // * `uri` {String} containing a URI.
  itemForURI (uri) {
    return this.items.find(item => {
      if (typeof item.getURI === 'function') {
        return item.getURI() === uri
      } else if (typeof item.getUri === 'function') {
        return item.getUri() === uri
      }
    })
  }

  // Public: Activate the first item that matches the given URI.
  //
  // * `uri` {String} containing a URI.
  //
  // Returns a {Boolean} indicating whether an item matching the URI was found.
  activateItemForURI (uri) {
    const item = this.itemForURI(uri)
    if (item) {
      this.activateItem(item)
      return true
    } else {
      return false
    }
  }

  copyActiveItem () {
    if (this.activeItem && typeof this.activeItem.copy === 'function') {
      return this.activeItem.copy()
    }
  }

  /*
  Section: Lifecycle
  */

  // Public: Determine whether the pane is active.
  //
  // Returns a {Boolean}.
  isActive () {
    return this.container && this.container.getActivePane() === this
  }

  // Public: Makes this pane the *active* pane, causing it to gain focus.
  activate () {
    if (this.isDestroyed()) throw new Error('Pane has been destroyed')
    if (this.container) this.container.didActivatePane(this)
    this.emitter.emit('did-activate')
  }

  // Public: Close the pane and destroy all its items.
  //
  // If this is the last pane, all the items will be destroyed but the pane
  // itself will not be destroyed.
  destroy () {
    if (this.container && this.container.isAlive() && this.container.getPanes().length === 1) {
      return this.destroyItems()
    }

    this.emitter.emit('will-destroy')
    this.alive = false
    if (this.container) {
      this.container.willDestroyPane({pane: this})
      if (this.isActive()) this.container.activateNextPane()
    }
    this.emitter.emit('did-destroy')
    this.emitter.dispose()
    for (let item of this.items.slice()) {
      if (typeof item.destroy === 'function') item.destroy()
    }
    if (this.container) this.container.didDestroyPane({pane: this})
  }

  isAlive () { return this.alive }

  // Public: Determine whether this pane has been destroyed.
  //
  // Returns a {Boolean}.
  isDestroyed () { return !this.isAlive() }

  /*
  Section: Splitting
  */

  // Public: Create a new pane to the left of this pane.
  //
  // * `params` (optional) {Object} with the following keys:
  //   * `items` (optional) {Array} of items to add to the new pane.
  //   * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
  //
  // Returns the new {Pane}.
  splitLeft (params) {
    return this.split('horizontal', 'before', params)
  }

  // Public: Create a new pane to the right of this pane.
  //
  // * `params` (optional) {Object} with the following keys:
  //   * `items` (optional) {Array} of items to add to the new pane.
  //   * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
  //
  // Returns the new {Pane}.
  splitRight (params) {
    return this.split('horizontal', 'after', params)
  }

  // Public: Creates a new pane above the receiver.
  //
  // * `params` (optional) {Object} with the following keys:
  //   * `items` (optional) {Array} of items to add to the new pane.
  //   * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
  //
  // Returns the new {Pane}.
  splitUp (params) {
    return this.split('vertical', 'before', params)
  }

  // Public: Creates a new pane below the receiver.
  //
  // * `params` (optional) {Object} with the following keys:
  //   * `items` (optional) {Array} of items to add to the new pane.
  //   * `copyActiveItem` (optional) {Boolean} true will copy the active item into the new split pane
  //
  // Returns the new {Pane}.
  splitDown (params) {
    return this.split('vertical', 'after', params)
  }

  split (orientation, side, params) {
    if (params && params.copyActiveItem) {
      if (!params.items) params.items = []
      params.items.push(this.copyActiveItem())
    }

    if (this.parent.orientation !== orientation) {
      this.parent.replaceChild(this, new PaneAxis({
        container: this.container,
        orientation,
        children: [this],
        flexScale: this.flexScale},
        this.viewRegistry
      ))
      this.setFlexScale(1)
    }

    const newPane = new Pane(Object.assign({
      applicationDelegate: this.applicationDelegate,
      notificationManager: this.notificationManager,
      deserializerManager: this.deserializerManager,
      config: this.config,
      viewRegistry: this.viewRegistry
    }, params))

    switch (side) {
      case 'before': this.parent.insertChildBefore(this, newPane); break
      case 'after': this.parent.insertChildAfter(this, newPane); break
    }

    if (params && params.moveActiveItem && this.activeItem) this.moveItemToPane(this.activeItem, newPane)

    newPane.activate()
    return newPane
  }

  // If the parent is a horizontal axis, returns its first child if it is a pane;
  // otherwise returns this pane.
  findLeftmostSibling () {
    if (this.parent.orientation === 'horizontal') {
      const [leftmostSibling] = this.parent.children
      if (leftmostSibling instanceof PaneAxis) {
        return this
      } else {
        return leftmostSibling
      }
    } else {
      return this
    }
  }

  findRightmostSibling () {
    if (this.parent.orientation === 'horizontal') {
      const rightmostSibling = this.parent.children[this.parent.children.length - 1]
      if (rightmostSibling instanceof PaneAxis) {
        return this
      } else {
        return rightmostSibling
      }
    } else {
      return this
    }
  }

  // If the parent is a horizontal axis, returns its last child if it is a pane;
  // otherwise returns a new pane created by splitting this pane rightward.
  findOrCreateRightmostSibling () {
    const rightmostSibling = this.findRightmostSibling()
    if (rightmostSibling === this) {
      return this.splitRight()
    } else {
      return rightmostSibling
    }
  }

  // If the parent is a vertical axis, returns its first child if it is a pane;
  // otherwise returns this pane.
  findTopmostSibling () {
    if (this.parent.orientation === 'vertical') {
      const [topmostSibling] = this.parent.children
      if (topmostSibling instanceof PaneAxis) {
        return this
      } else {
        return topmostSibling
      }
    } else {
      return this
    }
  }

  findBottommostSibling () {
    if (this.parent.orientation === 'vertical') {
      const bottommostSibling = this.parent.children[this.parent.children.length - 1]
      if (bottommostSibling instanceof PaneAxis) {
        return this
      } else {
        return bottommostSibling
      }
    } else {
      return this
    }
  }

  // If the parent is a vertical axis, returns its last child if it is a pane;
  // otherwise returns a new pane created by splitting this pane bottomward.
  findOrCreateBottommostSibling () {
    const bottommostSibling = this.findBottommostSibling()
    if (bottommostSibling === this) {
      return this.splitDown()
    } else {
      return bottommostSibling
    }
  }

  // Private: Close the pane unless the user cancels the action via a dialog.
  //
  // Returns a {Promise} that resolves once the pane is either closed, or the
  // closing has been cancelled.
  close () {
    return Promise.all(this.getItems().map(item => this.promptToSaveItem(item)))
      .then(results => {
        if (!results.includes(false)) return this.destroy()
      })
  }

  handleSaveError (error, item) {
    const itemPath = error.path || (typeof item.getPath === 'function' && item.getPath())
    const addWarningWithPath = (message, options) => {
      if (itemPath) message = `${message} '${itemPath}'`
      this.notificationManager.addWarning(message, options)
    }

    const customMessage = this.getMessageForErrorCode(error.code)
    if (customMessage != null) {
      addWarningWithPath(`Unable to save file: ${customMessage}`)
    } else if (error.code === 'EISDIR' || (error.message && error.message.endsWith('is a directory'))) {
      return this.notificationManager.addWarning(`Unable to save file: ${error.message}`)
    } else if (['EPERM', 'EBUSY', 'UNKNOWN', 'EEXIST', 'ELOOP', 'EAGAIN'].includes(error.code)) {
      addWarningWithPath('Unable to save file', {detail: error.message})
    } else {
      const errorMatch = /ENOTDIR, not a directory '([^']+)'/.exec(error.message)
      if (errorMatch) {
        const fileName = errorMatch[1]
        this.notificationManager.addWarning(`Unable to save file: A directory in the path '${fileName}' could not be written to`)
      } else {
        throw error
      }
    }
  }

  getMessageForErrorCode (errorCode) {
    switch (errorCode) {
      case 'EACCES': return 'Permission denied'
      case 'ECONNRESET': return 'Connection reset'
      case 'EINTR': return 'Interrupted system call'
      case 'EIO': return 'I/O error writing file'
      case 'ENOSPC': return 'No space left on device'
      case 'ENOTSUP': return 'Operation not supported on socket'
      case 'ENXIO': return 'No such device or address'
      case 'EROFS': return 'Read-only file system'
      case 'ESPIPE': return 'Invalid seek'
      case 'ETIMEDOUT': return 'Connection timed out'
    }
  }
}

function promisify (callback) {
  try {
    return Promise.resolve(callback())
  } catch (error) {
    return Promise.reject(error)
  }
}
