import {Selection, NodeSelection, TextSelection, AllSelection, EditorState} from "prosemirror-state"
import {EditorView} from "./index"
import * as browser from "./browser"
import {domIndex, selectionCollapsed} from "./dom"
import {selectionToDOM} from "./selection"

function moveSelectionBlock(state: EditorState, dir: number) {
  let {$anchor, $head} = state.selection
  let $side = dir > 0 ? $anchor.max($head) : $anchor.min($head)
  let $start = !$side.parent.inlineContent ? $side : $side.depth ? state.doc.resolve(dir > 0 ? $side.after() : $side.before()) : null
  return $start && Selection.findFrom($start, dir)
}

function apply(view: EditorView, sel: Selection) {
  view.dispatch(view.state.tr.setSelection(sel).scrollIntoView())
  return true
}

function selectHorizontally(view: EditorView, dir: number, mods: string) {
  let sel = view.state.selection
  if (sel instanceof TextSelection) {
    if (!sel.empty || mods.indexOf("s") > -1) {
      return false
    } else if (view.endOfTextblock(dir > 0 ? "right" : "left")) {
      let next = moveSelectionBlock(view.state, dir)
      if (next && (next instanceof NodeSelection)) return apply(view, next)
      return false
    } else if (!(browser.mac && mods.indexOf("m") > -1)) {
      let $head = sel.$head, node = $head.textOffset ? null : dir < 0 ? $head.nodeBefore : $head.nodeAfter, desc
      if (!node || node.isText) return false
      let nodePos = dir < 0 ? $head.pos - node.nodeSize : $head.pos
      if (!(node.isAtom || (desc = view.docView.descAt(nodePos)) && !desc.contentDOM)) return false
      if (NodeSelection.isSelectable(node)) {
        return apply(view, new NodeSelection(dir < 0 ? view.state.doc.resolve($head.pos - node.nodeSize) : $head))
      } else if (browser.webkit) {
        // Chrome and Safari will introduce extra pointless cursor
        // positions around inline uneditable nodes, so we have to
        // take over and move the cursor past them (#937)
        return apply(view, new TextSelection(view.state.doc.resolve(dir < 0 ? nodePos : nodePos + node.nodeSize)))
      } else {
        return false
      }
    }
  } else if (sel instanceof NodeSelection && sel.node.isInline) {
    return apply(view, new TextSelection(dir > 0 ? sel.$to : sel.$from))
  } else {
    let next = moveSelectionBlock(view.state, dir)
    if (next) return apply(view, next)
    return false
  }
}

function nodeLen(node: Node) {
  return node.nodeType == 3 ? node.nodeValue!.length : node.childNodes.length
}

function isIgnorable(dom: Node) {
  let desc = dom.pmViewDesc
  return desc && desc.size == 0 && (dom.nextSibling || dom.nodeName != "BR")
}

// Make sure the cursor isn't directly after one or more ignored
// nodes, which will confuse the browser's cursor motion logic.
function skipIgnoredNodesLeft(view: EditorView) {
  let sel = view.domSelectionRange()
  let node = sel.focusNode!, offset = sel.focusOffset
  if (!node) return
  let moveNode, moveOffset: number | undefined, force = false
  // Gecko will do odd things when the selection is directly in front
  // of a non-editable node, so in that case, move it into the next
  // node if possible. Issue prosemirror/prosemirror#832.
  if (browser.gecko && node.nodeType == 1 && offset < nodeLen(node) && isIgnorable(node.childNodes[offset])) force = true
  for (;;) {
    if (offset > 0) {
      if (node.nodeType != 1) {
        break
      } else {
        let before = node.childNodes[offset - 1]
        if (isIgnorable(before)) {
          moveNode = node
          moveOffset = --offset
        } else if (before.nodeType == 3) {
          node = before
          offset = node.nodeValue!.length
        } else break
      }
    } else if (isBlockNode(node)) {
      break
    } else {
      let prev = node.previousSibling
      while (prev && isIgnorable(prev)) {
        moveNode = node.parentNode
        moveOffset = domIndex(prev)
        prev = prev.previousSibling
      }
      if (!prev) {
        node = node.parentNode!
        if (node == view.dom) break
        offset = 0
      } else {
        node = prev
        offset = nodeLen(node)
      }
    }
  }
  if (force) setSelFocus(view, node, offset)
  else if (moveNode) setSelFocus(view, moveNode, moveOffset!)
}

// Make sure the cursor isn't directly before one or more ignored
// nodes.
function skipIgnoredNodesRight(view: EditorView) {
  let sel = view.domSelectionRange()
  let node = sel.focusNode!, offset = sel.focusOffset
  if (!node) return
  let len = nodeLen(node)
  let moveNode, moveOffset: number | undefined
  for (;;) {
    if (offset < len) {
      if (node.nodeType != 1) break
      let after = node.childNodes[offset]
      if (isIgnorable(after)) {
        moveNode = node
        moveOffset = ++offset
      }
      else break
    } else if (isBlockNode(node)) {
      break
    } else {
      let next = node.nextSibling
      while (next && isIgnorable(next)) {
        moveNode = next.parentNode
        moveOffset = domIndex(next) + 1
        next = next.nextSibling
      }
      if (!next) {
        node = node.parentNode!
        if (node == view.dom) break
        offset = len = 0
      } else {
        node = next
        offset = 0
        len = nodeLen(node)
      }
    }
  }
  if (moveNode) setSelFocus(view, moveNode, moveOffset!)
}

function isBlockNode(dom: Node) {
  let desc = dom.pmViewDesc
  return desc && desc.node && desc.node.isBlock
}

function setSelFocus(view: EditorView, node: Node, offset: number) {
  let sel = view.domSelection()
  if (selectionCollapsed(sel)) {
    let range = document.createRange()
    range.setEnd(node, offset)
    range.setStart(node, offset)
    sel.removeAllRanges()
    sel.addRange(range)
  } else if (sel.extend) {
    sel.extend(node, offset)
  }
  view.domObserver.setCurSelection()
  let {state} = view
  // If no state update ends up happening, reset the selection.
  setTimeout(() => {
    if (view.state == state) selectionToDOM(view)
  }, 50)
}

// Check whether vertical selection motion would involve node
// selections. If so, apply it (if not, the result is left to the
// browser)
function selectVertically(view: EditorView, dir: number, mods: string) {
  let sel = view.state.selection
  if (sel instanceof TextSelection && !sel.empty || mods.indexOf("s") > -1) return false
  if (browser.mac && mods.indexOf("m") > -1) return false
  let {$from, $to} = sel

  if (!$from.parent.inlineContent || view.endOfTextblock(dir < 0 ? "up" : "down")) {
    let next = moveSelectionBlock(view.state, dir)
    if (next && (next instanceof NodeSelection))
      return apply(view, next)
  }
  if (!$from.parent.inlineContent) {
    let side = dir < 0 ? $from : $to
    let beyond = sel instanceof AllSelection ? Selection.near(side, dir) : Selection.findFrom(side, dir)
    return beyond ? apply(view, beyond) : false
  }
  return false
}

function stopNativeHorizontalDelete(view: EditorView, dir: number) {
  if (!(view.state.selection instanceof TextSelection)) return true
  let {$head, $anchor, empty} = view.state.selection
  if (!$head.sameParent($anchor)) return true
  if (!empty) return false
  if (view.endOfTextblock(dir > 0 ? "forward" : "backward")) return true
  let nextNode = !$head.textOffset && (dir < 0 ? $head.nodeBefore : $head.nodeAfter)
  if (nextNode && !nextNode.isText) {
    let tr = view.state.tr
    if (dir < 0) tr.delete($head.pos - nextNode.nodeSize, $head.pos)
    else tr.delete($head.pos, $head.pos + nextNode.nodeSize)
    view.dispatch(tr)
    return true
  }
  return false
}

function switchEditable(view: EditorView, node: HTMLElement, state: string) {
  view.domObserver.stop()
  node.contentEditable = state
  view.domObserver.start()
}

// Issue #867 / #1090 / https://bugs.chromium.org/p/chromium/issues/detail?id=903821
// In which Safari (and at some point in the past, Chrome) does really
// wrong things when the down arrow is pressed when the cursor is
// directly at the start of a textblock and has an uneditable node
// after it
function safariDownArrowBug(view: EditorView) {
  if (!browser.safari || view.state.selection.$head.parentOffset > 0) return false
  let {focusNode, focusOffset} = view.domSelectionRange()
  if (focusNode && focusNode.nodeType == 1 && focusOffset == 0 &&
      focusNode.firstChild && (focusNode.firstChild as HTMLElement).contentEditable == "false") {
    let child = focusNode.firstChild as HTMLElement
    switchEditable(view, child, "true")
    setTimeout(() => switchEditable(view, child, "false"), 20)
  }
  return false
}

// A backdrop key mapping used to make sure we always suppress keys
// that have a dangerous default effect, even if the commands they are
// bound to return false, and to make sure that cursor-motion keys
// find a cursor (as opposed to a node selection) when pressed. For
// cursor-motion keys, the code in the handlers also takes care of
// block selections.

function getMods(event: KeyboardEvent) {
  let result = ""
  if (event.ctrlKey) result += "c"
  if (event.metaKey) result += "m"
  if (event.altKey) result += "a"
  if (event.shiftKey) result += "s"
  return result
}

export function captureKeyDown(view: EditorView, event: KeyboardEvent) {
  let code = event.keyCode, mods = getMods(event)
  if (code == 8 || (browser.mac && code == 72 && mods == "c")) { // Backspace, Ctrl-h on Mac
    return stopNativeHorizontalDelete(view, -1) || skipIgnoredNodesLeft(view)
  } else if (code == 46 || (browser.mac && code == 68 && mods == "c")) { // Delete, Ctrl-d on Mac
    return stopNativeHorizontalDelete(view, 1) || skipIgnoredNodesRight(view)
  } else if (code == 13 || code == 27) { // Enter, Esc
    return true
  } else if (code == 37 || (browser.mac && code == 66 && mods == "c")) { // Left arrow, Ctrl-b on Mac
    return selectHorizontally(view, -1, mods) || skipIgnoredNodesLeft(view)
  } else if (code == 39 || (browser.mac && code == 70 && mods == "c")) { // Right arrow, Ctrl-f on Mac
    return selectHorizontally(view, 1, mods) || skipIgnoredNodesRight(view)
  } else if (code == 38 || (browser.mac && code == 80 && mods == "c")) { // Up arrow, Ctrl-p on Mac
    return selectVertically(view, -1, mods) || skipIgnoredNodesLeft(view)
  } else if (code == 40 || (browser.mac && code == 78 && mods == "c")) { // Down arrow, Ctrl-n on Mac
    return safariDownArrowBug(view) || selectVertically(view, 1, mods) || skipIgnoredNodesRight(view)
  } else if (mods == (browser.mac ? "m" : "c") &&
             (code == 66 || code == 73 || code == 89 || code == 90)) { // Mod-[biyz]
    return true
  }
  return false
}
