import {EditorState} from "prosemirror-state"
import {nodeSize, textRange, parentNode, caretFromPoint} from "./dom"
import * as browser from "./browser"
import {EditorView} from "./index"

export type Rect = {left: number, right: number, top: number, bottom: number}

function windowRect(doc: Document): Rect {
  let vp = doc.defaultView && doc.defaultView.visualViewport
  if (vp) return {
    left: 0, right: vp.width,
    top: 0, bottom: vp.height
  }
  return {left: 0, right: doc.documentElement.clientWidth,
          top: 0, bottom: doc.documentElement.clientHeight}
}

function getSide(value: number | Rect, side: keyof Rect): number {
  return typeof value == "number" ? value : value[side]
}

function clientRect(node: HTMLElement): Rect {
  let rect = node.getBoundingClientRect()
  // Adjust for elements with style "transform: scale()"
  let scaleX = (rect.width / node.offsetWidth) || 1
  let scaleY = (rect.height / node.offsetHeight) || 1
  // Make sure scrollbar width isn't included in the rectangle
  return {left: rect.left, right: rect.left + node.clientWidth * scaleX,
          top: rect.top, bottom: rect.top + node.clientHeight * scaleY}
}

export function scrollRectIntoView(view: EditorView, rect: Rect, startDOM: Node) {
  let scrollThreshold = view.someProp("scrollThreshold") || 0, scrollMargin = view.someProp("scrollMargin") || 5
  let doc = view.dom.ownerDocument
  for (let parent: Node | null = startDOM || view.dom;;) {
    if (!parent) break
    if (parent.nodeType != 1) { parent = parentNode(parent); continue }
    let elt = parent as HTMLElement
    let atTop = elt == doc.body
    let bounding = atTop ? windowRect(doc) : clientRect(elt as HTMLElement)
    let moveX = 0, moveY = 0
    if (rect.top < bounding.top + getSide(scrollThreshold, "top"))
      moveY = -(bounding.top - rect.top + getSide(scrollMargin, "top"))
    else if (rect.bottom > bounding.bottom - getSide(scrollThreshold, "bottom"))
      moveY = rect.bottom - rect.top > bounding.bottom - bounding.top
        ? rect.top + getSide(scrollMargin, "top") - bounding.top
        : rect.bottom - bounding.bottom + getSide(scrollMargin, "bottom")
    if (rect.left < bounding.left + getSide(scrollThreshold, "left"))
      moveX = -(bounding.left - rect.left + getSide(scrollMargin, "left"))
    else if (rect.right > bounding.right - getSide(scrollThreshold, "right"))
      moveX = rect.right - bounding.right + getSide(scrollMargin, "right")
    if (moveX || moveY) {
      if (atTop) {
        doc.defaultView!.scrollBy(moveX, moveY)
      } else {
        let startX = elt.scrollLeft, startY = elt.scrollTop
        if (moveY) elt.scrollTop += moveY
        if (moveX) elt.scrollLeft += moveX
        let dX = elt.scrollLeft - startX, dY = elt.scrollTop - startY
        rect = {left: rect.left - dX, top: rect.top - dY, right: rect.right - dX, bottom: rect.bottom - dY}
      }
    }
    let pos: string = atTop ? "fixed" : getComputedStyle(parent as HTMLElement).position
    if (/^(fixed|sticky)$/.test(pos)) break
    parent = pos == "absolute" ? (parent as HTMLElement).offsetParent : parentNode(parent)
  }
}

// Store the scroll position of the editor's parent nodes, along with
// the top position of an element near the top of the editor, which
// will be used to make sure the visible viewport remains stable even
// when the size of the content above changes.
export function storeScrollPos(view: EditorView): {
  refDOM: HTMLElement,
  refTop: number,
  stack: {dom: HTMLElement, top: number, left: number}[]
} {
  let rect = view.dom.getBoundingClientRect(), startY = Math.max(0, rect.top)
  let refDOM: HTMLElement, refTop: number
  for (let x = (rect.left + rect.right) / 2, y = startY + 1;
       y < Math.min(innerHeight, rect.bottom); y += 5) {
    let dom = view.root.elementFromPoint(x, y)
    if (!dom || dom == view.dom || !view.dom.contains(dom)) continue
    let localRect = (dom as HTMLElement).getBoundingClientRect()
    if (localRect.top >= startY - 20) {
      refDOM = dom as HTMLElement
      refTop = localRect.top
      break
    }
  }
  return {refDOM: refDOM!, refTop: refTop!, stack: scrollStack(view.dom)}
}

function scrollStack(dom: Node): {dom: HTMLElement, top: number, left: number}[] {
  let stack = [], doc = dom.ownerDocument
  for (let cur: Node | null = dom; cur; cur = parentNode(cur)) {
    stack.push({dom: cur as HTMLElement, top: (cur as HTMLElement).scrollTop, left: (cur as HTMLElement).scrollLeft})
    if (dom == doc) break
  }
  return stack
}

// Reset the scroll position of the editor's parent nodes to that what
// it was before, when storeScrollPos was called.
export function resetScrollPos({refDOM, refTop, stack}: {
  refDOM: HTMLElement,
  refTop: number,
  stack: {dom: HTMLElement, top: number, left: number}[]
}) {
  let newRefTop = refDOM ? refDOM.getBoundingClientRect().top : 0
  restoreScrollStack(stack, newRefTop == 0 ? 0 : newRefTop - refTop)
}

function restoreScrollStack(stack: {dom: HTMLElement, top: number, left: number}[], dTop: number) {
  for (let i = 0; i < stack.length; i++) {
    let {dom, top, left} = stack[i]
    if (dom.scrollTop != top + dTop) dom.scrollTop = top + dTop
    if (dom.scrollLeft != left) dom.scrollLeft = left
  }
}

let preventScrollSupported: false | null | {preventScroll: boolean} = null
// Feature-detects support for .focus({preventScroll: true}), and uses
// a fallback kludge when not supported.
export function focusPreventScroll(dom: HTMLElement) {
  if ((dom as any).setActive) return (dom as any).setActive() // in IE
  if (preventScrollSupported) return dom.focus(preventScrollSupported)

  let stored = scrollStack(dom)
  dom.focus(preventScrollSupported == null ? {
    get preventScroll() {
      preventScrollSupported = {preventScroll: true}
      return true
    }
  } : undefined)
  if (!preventScrollSupported) {
    preventScrollSupported = false
    restoreScrollStack(stored, 0)
  }
}

function findOffsetInNode(node: HTMLElement, coords: {top: number, left: number}): {node: Node, offset: number} {
  let closest, dxClosest = 2e8, coordsClosest: {left: number, top: number} | undefined, offset = 0
  let rowBot = coords.top, rowTop = coords.top
  let firstBelow: Node | undefined, coordsBelow: {left: number, top: number} | undefined
  for (let child = node.firstChild, childIndex = 0; child; child = child.nextSibling, childIndex++) {
    let rects
    if (child.nodeType == 1) rects = (child as HTMLElement).getClientRects()
    else if (child.nodeType == 3) rects = textRange(child as Text).getClientRects()
    else continue

    for (let i = 0; i < rects.length; i++) {
      let rect = rects[i]
      if (rect.top <= rowBot && rect.bottom >= rowTop) {
        rowBot = Math.max(rect.bottom, rowBot)
        rowTop = Math.min(rect.top, rowTop)
        let dx = rect.left > coords.left ? rect.left - coords.left
            : rect.right < coords.left ? coords.left - rect.right : 0
        if (dx < dxClosest) {
          closest = child
          dxClosest = dx
          coordsClosest = dx && closest.nodeType == 3 ? {
            left: rect.right < coords.left ? rect.right : rect.left,
            top: coords.top
          } : coords
          if (child.nodeType == 1 && dx)
            offset = childIndex + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0)
          continue
        }
      } else if (rect.top > coords.top && !firstBelow && rect.left <= coords.left && rect.right >= coords.left) {
        firstBelow = child
        coordsBelow = {left: Math.max(rect.left, Math.min(rect.right, coords.left)), top: rect.top}
      }
      if (!closest && (coords.left >= rect.right && coords.top >= rect.top ||
                       coords.left >= rect.left && coords.top >= rect.bottom))
        offset = childIndex + 1
    }
  }
  if (!closest && firstBelow) { closest = firstBelow; coordsClosest = coordsBelow; dxClosest = 0 }
  if (closest && closest.nodeType == 3) return findOffsetInText(closest as Text, coordsClosest!)
  if (!closest || (dxClosest && closest.nodeType == 1)) return {node, offset}
  return findOffsetInNode(closest as HTMLElement, coordsClosest!)
}

function findOffsetInText(node: Text, coords: {top: number, left: number}) {
  let len = node.nodeValue!.length
  let range = document.createRange(), result: {node: Node, offset: number} | undefined
  for (let i = 0; i < len; i++) {
    range.setEnd(node, i + 1)
    range.setStart(node, i)
    let rect = singleRect(range, 1)
    if (rect.top == rect.bottom) continue
    if (inRect(coords, rect)) {
      result = {node, offset: i + (coords.left >= (rect.left + rect.right) / 2 ? 1 : 0)}
      break
    }
  }
  range.detach()
  return result || {node, offset: 0}
}

function inRect(coords: {top: number, left: number}, rect: Rect) {
  return coords.left >= rect.left - 1 && coords.left <= rect.right + 1&&
    coords.top >= rect.top - 1 && coords.top <= rect.bottom + 1
}

function targetKludge(dom: HTMLElement, coords: {top: number, left: number}) {
  let parent = dom.parentNode
  if (parent && /^li$/i.test(parent.nodeName) && coords.left < dom.getBoundingClientRect().left)
    return parent as HTMLElement
  return dom
}

function posFromElement(view: EditorView, elt: HTMLElement, coords: {top: number, left: number}) {
  let {node, offset} = findOffsetInNode(elt, coords), bias = -1
  if (node.nodeType == 1 && !node.firstChild) {
    let rect = (node as HTMLElement).getBoundingClientRect()
    bias = rect.left != rect.right && coords.left > (rect.left + rect.right) / 2 ? 1 : -1
  }
  return view.docView.posFromDOM(node, offset, bias)
}

function posFromCaret(view: EditorView, node: Node, offset: number, coords: {top: number, left: number}) {
  // Browser (in caretPosition/RangeFromPoint) will agressively
  // normalize towards nearby inline nodes. Since we are interested in
  // positions between block nodes too, we first walk up the hierarchy
  // of nodes to see if there are block nodes that the coordinates
  // fall outside of. If so, we take the position before/after that
  // block. If not, we call `posFromDOM` on the raw node/offset.
  let outsideBlock = -1
  for (let cur = node, sawBlock = false;;) {
    if (cur == view.dom) break
    let desc = view.docView.nearestDesc(cur, true), rect
    if (!desc) return null
    if (desc.dom.nodeType == 1 && (desc.node.isBlock && desc.parent || !desc.contentDOM) &&
        // Ignore elements with zero-size bounding rectangles
        ((rect = (desc.dom as HTMLElement).getBoundingClientRect()).width || rect.height)) {
      if (desc.node.isBlock && desc.parent && !/^T(R|BODY|HEAD|FOOT)$/.test(desc.dom!.nodeName)) {
        // Only apply the horizontal test to the innermost block. Vertical for any parent.
        if (!sawBlock && rect.left > coords.left || rect.top > coords.top) outsideBlock = desc.posBefore
        else if (!sawBlock && rect.right < coords.left || rect.bottom < coords.top) outsideBlock = desc.posAfter
        sawBlock = true
      }
      if (!desc.contentDOM && outsideBlock < 0 && !desc.node.isText) {
        // If we are inside a leaf, return the side of the leaf closer to the coords
        let before = desc.node.isBlock ? coords.top < (rect.top + rect.bottom) / 2
          : coords.left < (rect.left + rect.right) / 2
        return before ? desc.posBefore : desc.posAfter
      }
    }
    cur = desc.dom.parentNode!
  }
  return outsideBlock > -1 ? outsideBlock : view.docView.posFromDOM(node, offset, -1)
}

function elementFromPoint(element: HTMLElement, coords: {top: number, left: number}, box: Rect): HTMLElement {
  let len = element.childNodes.length
  if (len && box.top < box.bottom) {
    for (let startI = Math.max(0, Math.min(len - 1, Math.floor(len * (coords.top - box.top) / (box.bottom - box.top)) - 2)), i = startI;;) {
      let child = element.childNodes[i]
      if (child.nodeType == 1) {
        let rects = (child as HTMLElement).getClientRects()
        for (let j = 0; j < rects.length; j++) {
          let rect = rects[j]
          if (inRect(coords, rect)) return elementFromPoint(child as HTMLElement, coords, rect)
        }
      }
      if ((i = (i + 1) % len) == startI) break
    }
  }
  return element
}

// Given an x,y position on the editor, get the position in the document.
export function posAtCoords(view: EditorView, coords: {top: number, left: number}) {
  let doc = view.dom.ownerDocument, node: Node | undefined, offset = 0
  let caret = caretFromPoint(doc, coords.left, coords.top)
  if (caret) ({node, offset} = caret)

  let elt = ((view.root as any).elementFromPoint ? view.root : doc)
              .elementFromPoint(coords.left, coords.top) as HTMLElement
  let pos
  if (!elt || !view.dom.contains(elt.nodeType != 1 ? elt.parentNode : elt)) {
    let box = view.dom.getBoundingClientRect()
    if (!inRect(coords, box)) return null
    elt = elementFromPoint(view.dom, coords, box)
    if (!elt) return null
  }
  // Safari's caretRangeFromPoint returns nonsense when on a draggable element
  if (browser.safari) {
    for (let p: Node | null = elt; node && p; p = parentNode(p))
      if ((p as HTMLElement).draggable) node = undefined
  }
  elt = targetKludge(elt, coords)
  if (node) {
    if (browser.gecko && node.nodeType == 1) {
      // Firefox will sometimes return offsets into <input> nodes, which
      // have no actual children, from caretPositionFromPoint (#953)
      offset = Math.min(offset, node.childNodes.length)
      // It'll also move the returned position before image nodes,
      // even if those are behind it.
      if (offset < node.childNodes.length) {
        let next = node.childNodes[offset], box
        if (next.nodeName == "IMG" && (box = (next as HTMLElement).getBoundingClientRect()).right <= coords.left &&
            box.bottom > coords.top)
          offset++
      }
    }
    let prev
    // When clicking above the right side of an uneditable node, Chrome will report a cursor position after that node.
    if (browser.webkit && offset && node.nodeType == 1 && (prev = node.childNodes[offset - 1]).nodeType == 1 &&
        (prev as HTMLElement).contentEditable == "false" && (prev as HTMLElement).getBoundingClientRect().top >= coords.top)
      offset--
    // Suspiciously specific kludge to work around caret*FromPoint
    // never returning a position at the end of the document
    if (node == view.dom && offset == node.childNodes.length - 1 && node.lastChild!.nodeType == 1 &&
        coords.top > (node.lastChild as HTMLElement).getBoundingClientRect().bottom)
      pos = view.state.doc.content.size
    // Ignore positions directly after a BR, since caret*FromPoint
    // 'round up' positions that would be more accurately placed
    // before the BR node.
    else if (offset == 0 || node.nodeType != 1 || node.childNodes[offset - 1].nodeName != "BR")
      pos = posFromCaret(view, node, offset, coords)
  }
  if (pos == null) pos = posFromElement(view, elt, coords)

  let desc = view.docView.nearestDesc(elt, true)
  return {pos, inside: desc ? desc.posAtStart - desc.border : -1}
}

function nonZero(rect: DOMRect) {
  return rect.top < rect.bottom || rect.left < rect.right
}

function singleRect(target: HTMLElement | Range, bias: number): DOMRect {
  let rects = target.getClientRects()
  if (rects.length) {
    let first = rects[bias < 0 ? 0 : rects.length - 1]
    if (nonZero(first)) return first
  }
  return Array.prototype.find.call(rects, nonZero) || target.getBoundingClientRect()
}

const BIDI = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/

// Given a position in the document model, get a bounding box of the
// character at that position, relative to the window.
export function coordsAtPos(view: EditorView, pos: number, side: number): Rect {
  let {node, offset, atom} = view.docView.domFromPos(pos, side < 0 ? -1 : 1)

  let supportEmptyRange = browser.webkit || browser.gecko
  if (node.nodeType == 3) {
    // These browsers support querying empty text ranges. Prefer that in
    // bidi context or when at the end of a node.
    if (supportEmptyRange && (BIDI.test(node.nodeValue!) || (side < 0 ? !offset : offset == node.nodeValue!.length))) {
      let rect = singleRect(textRange(node as Text, offset, offset), side)
      // Firefox returns bad results (the position before the space)
      // when querying a position directly after line-broken
      // whitespace. Detect this situation and and kludge around it
      if (browser.gecko && offset && /\s/.test(node.nodeValue![offset - 1]) && offset < node.nodeValue!.length) {
        let rectBefore = singleRect(textRange(node as Text, offset - 1, offset - 1), -1)
        if (rectBefore.top == rect.top) {
          let rectAfter = singleRect(textRange(node as Text, offset, offset + 1), -1)
          if (rectAfter.top != rect.top)
            return flattenV(rectAfter, rectAfter.left < rectBefore.left)
        }
      }
      return rect
    } else {
      let from = offset, to = offset, takeSide = side < 0 ? 1 : -1
      if (side < 0 && !offset) { to++; takeSide = -1 }
      else if (side >= 0 && offset == node.nodeValue!.length) { from--; takeSide = 1 }
      else if (side < 0) { from-- }
      else { to ++ }
      return flattenV(singleRect(textRange(node as Text, from, to), takeSide), takeSide < 0)
    }
  }

  let $dom = view.state.doc.resolve(pos - (atom || 0))
  // Return a horizontal line in block context
  if (!$dom.parent.inlineContent) {
    if (atom == null && offset && (side < 0 || offset == nodeSize(node))) {
      let before = node.childNodes[offset - 1]
      if (before.nodeType == 1) return flattenH((before as HTMLElement).getBoundingClientRect(), false)
    }
    if (atom == null && offset < nodeSize(node)) {
      let after = node.childNodes[offset]
      if (after.nodeType == 1) return flattenH((after as HTMLElement).getBoundingClientRect(), true)
    }
    return flattenH((node as HTMLElement).getBoundingClientRect(), side >= 0)
  }

  // Inline, not in text node (this is not Bidi-safe)
  if (atom == null && offset && (side < 0 || offset == nodeSize(node))) {
    let before = node.childNodes[offset - 1]
    let target = before.nodeType == 3 ? textRange(before as Text, nodeSize(before) - (supportEmptyRange ? 0 : 1))
        // BR nodes tend to only return the rectangle before them.
        // Only use them if they are the last element in their parent
        : before.nodeType == 1 && (before.nodeName != "BR" || !before.nextSibling) ? before : null
    if (target) return flattenV(singleRect(target as Range | HTMLElement, 1), false)
  }
  if (atom == null && offset < nodeSize(node)) {
    let after = node.childNodes[offset]
    while (after.pmViewDesc && after.pmViewDesc.ignoreForCoords) after = after.nextSibling!
    let target = !after ? null : after.nodeType == 3 ? textRange(after as Text, 0, (supportEmptyRange ? 0 : 1))
        : after.nodeType == 1 ? after : null
    if (target) return flattenV(singleRect(target as Range | HTMLElement, -1), true)
  }
  // All else failed, just try to get a rectangle for the target node
  return flattenV(singleRect(node.nodeType == 3 ? textRange(node as Text) : node as HTMLElement, -side), side >= 0)
}

function flattenV(rect: DOMRect, left: boolean) {
  if (rect.width == 0) return rect
  let x = left ? rect.left : rect.right
  return {top: rect.top, bottom: rect.bottom, left: x, right: x}
}

function flattenH(rect: DOMRect, top: boolean) {
  if (rect.height == 0) return rect
  let y = top ? rect.top : rect.bottom
  return {top: y, bottom: y, left: rect.left, right: rect.right}
}

function withFlushedState<T>(view: EditorView, state: EditorState, f: () => T): T {
  let viewState = view.state, active = view.root.activeElement as HTMLElement
  if (viewState != state) view.updateState(state)
  if (active != view.dom) view.focus()
  try {
    return f()
  } finally {
    if (viewState != state) view.updateState(viewState)
    if (active != view.dom && active) active.focus()
  }
}

// Whether vertical position motion in a given direction
// from a position would leave a text block.
function endOfTextblockVertical(view: EditorView, state: EditorState, dir: "up" | "down") {
  let sel = state.selection
  let $pos = dir == "up" ? sel.$from : sel.$to
  return withFlushedState(view, state, () => {
    let {node: dom} = view.docView.domFromPos($pos.pos, dir == "up" ? -1 : 1)
    for (;;) {
      let nearest = view.docView.nearestDesc(dom, true)
      if (!nearest) break
      if (nearest.node.isBlock) { dom = nearest.contentDOM || nearest.dom; break }
      dom = nearest.dom.parentNode!
    }
    let coords = coordsAtPos(view, $pos.pos, 1)
    for (let child = dom.firstChild; child; child = child.nextSibling) {
      let boxes
      if (child.nodeType == 1) boxes = (child as HTMLElement).getClientRects()
      else if (child.nodeType == 3) boxes = textRange(child as Text, 0, child.nodeValue!.length).getClientRects()
      else continue
      for (let i = 0; i < boxes.length; i++) {
        let box = boxes[i]
        if (box.bottom > box.top + 1 &&
            (dir == "up" ? coords.top - box.top > (box.bottom - coords.top) * 2
             : box.bottom - coords.bottom > (coords.bottom - box.top) * 2))
          return false
      }
    }
    return true
  })
}

const maybeRTL = /[\u0590-\u08ac]/

function endOfTextblockHorizontal(view: EditorView, state: EditorState, dir: "left" | "right" | "forward" | "backward") {
  let {$head} = state.selection
  if (!$head.parent.isTextblock) return false
  let offset = $head.parentOffset, atStart = !offset, atEnd = offset == $head.parent.content.size
  let sel: Selection = view.domSelection()!
  if (!sel) return $head.pos == $head.start() || $head.pos == $head.end()
  // If the textblock is all LTR, or the browser doesn't support
  // Selection.modify (Edge), fall back to a primitive approach
  if (!maybeRTL.test($head.parent.textContent) || !(sel as any).modify)
    return dir == "left" || dir == "backward" ? atStart : atEnd

  return withFlushedState(view, state, () => {
    // This is a huge hack, but appears to be the best we can
    // currently do: use `Selection.modify` to move the selection by
    // one character, and see if that moves the cursor out of the
    // textblock (or doesn't move it at all, when at the start/end of
    // the document).
    let {focusNode: oldNode, focusOffset: oldOff, anchorNode, anchorOffset} = view.domSelectionRange()
    let oldBidiLevel = (sel as any).caretBidiLevel // Only for Firefox
    ;(sel as any).modify("move", dir, "character")
    let parentDOM = $head.depth ? view.docView.domAfterPos($head.before()) : view.dom
    let {focusNode: newNode, focusOffset: newOff} = view.domSelectionRange()
    let result = newNode && !parentDOM.contains(newNode.nodeType == 1 ? newNode : newNode.parentNode) ||
        (oldNode == newNode && oldOff == newOff)
    // Restore the previous selection
    try {
      sel.collapse(anchorNode, anchorOffset)
      if (oldNode && (oldNode != anchorNode || oldOff != anchorOffset) && sel.extend) sel.extend(oldNode, oldOff)
    } catch (_) {}
    if (oldBidiLevel != null) (sel as any).caretBidiLevel = oldBidiLevel
    return result
  })
}

export type TextblockDir = "up" | "down" | "left" | "right" | "forward" | "backward"

let cachedState: EditorState | null = null
let cachedDir: TextblockDir | null = null
let cachedResult: boolean = false
export function endOfTextblock(view: EditorView, state: EditorState, dir: TextblockDir) {
  if (cachedState == state && cachedDir == dir) return cachedResult
  cachedState = state; cachedDir = dir
  return cachedResult = dir == "up" || dir == "down"
    ? endOfTextblockVertical(view, state, dir)
    : endOfTextblockHorizontal(view, state, dir)
}
