'use strict'

import jsonrepair from 'jsonrepair'
import ace from './ace'
import { DEFAULT_MODAL_ANCHOR } from './constants'
import { ErrorTable } from './ErrorTable'
import { FocusTracker } from './FocusTracker'
import { setLanguage, setLanguages, translate } from './i18n'
import { createQuery, executeQuery } from './jmespathQuery'
import { ModeSwitcher } from './ModeSwitcher'
import { showSortModal } from './showSortModal'
import { showTransformModal } from './showTransformModal'
import { tryRequireThemeJsonEditor } from './tryRequireThemeJsonEditor'
import { SchemaTextCompleter } from './SchemaTextCompleter'
import {
  addClassName,
  debounce,
  escapeUnicodeChars,
  getIndexForPosition,
  getInputSelection,
  getPositionForPath,
  improveSchemaError,
  isObject,
  isValidationErrorChanged,
  parse,
  sort,
  sortObjectKeys
} from './util'
import { validateCustom } from './validationUtils'

// create a mixin with the functions for text mode
const textmode = {}

const DEFAULT_THEME = 'ace/theme/jsoneditor'

/**
 * Create a text editor
 * @param {Element} container
 * @param {Object} [options]   Object with options. See docs for details.
 * @private
 */
textmode.create = function (container, options = {}) {
  if (typeof options.statusBar === 'undefined') {
    options.statusBar = true
  }

  // setting default for textmode
  options.mainMenuBar = options.mainMenuBar !== false
  options.enableSort = options.enableSort !== false
  options.enableTransform = options.enableTransform !== false
  options.createQuery = options.createQuery || createQuery
  options.executeQuery = options.executeQuery || executeQuery

  this.options = options

  // indentation
  if (typeof options.indentation === 'number') {
    this.indentation = Number(options.indentation)
  } else {
    this.indentation = 2 // number of spaces
  }

  // language
  setLanguages(this.options.languages)
  setLanguage(this.options.language)

  // grab ace from options if provided
  const _ace = options.ace ? options.ace : ace
  // TODO: make the option options.ace deprecated, it's not needed anymore (see #309)

  // determine mode
  this.mode = (options.mode === 'code') ? 'code' : 'text'
  if (this.mode === 'code') {
    // verify whether Ace editor is available and supported
    if (typeof _ace === 'undefined') {
      this.mode = 'text'
      console.warn('Failed to load Ace editor, falling back to plain text mode. Please use a JSONEditor bundle including Ace, or pass Ace as via the configuration option `ace`.')
    }
  }

  // determine theme
  this.theme = options.theme || DEFAULT_THEME
  if (this.theme === DEFAULT_THEME && _ace) {
    tryRequireThemeJsonEditor()
  }

  if (options.onTextSelectionChange) {
    this.onTextSelectionChange(options.onTextSelectionChange)
  }

  const me = this
  this.container = container
  this.dom = {}
  this.aceEditor = undefined // ace code editor
  this.textarea = undefined // plain text editor (fallback when Ace is not available)
  this.validateSchema = null
  this.annotations = []
  this.lastSchemaErrors = undefined

  // create a debounced validate function
  this._debouncedValidate = debounce(this._validateAndCatch.bind(this), this.DEBOUNCE_INTERVAL)

  this.width = container.clientWidth
  this.height = container.clientHeight

  this.frame = document.createElement('div')
  this.frame.className = 'jsoneditor jsoneditor-mode-' + this.options.mode
  this.frame.onclick = event => {
    // prevent default submit action when the editor is located inside a form
    event.preventDefault()
  }
  this.frame.onkeydown = event => {
    me._onKeyDown(event)
  }

  // setting the FocusTracker on 'this.frame' to track the editor's focus event
  const focusTrackerConfig = {
    target: this.frame,
    onFocus: this.options.onFocus || null,
    onBlur: this.options.onBlur || null
  }
  this.frameFocusTracker = new FocusTracker(focusTrackerConfig)

  this.content = document.createElement('div')
  this.content.className = 'jsoneditor-outer'

  if (this.options.mainMenuBar) {
    addClassName(this.content, 'has-main-menu-bar')

    // create menu
    this.menu = document.createElement('div')
    this.menu.className = 'jsoneditor-menu'
    this.frame.appendChild(this.menu)

    // create format button
    const buttonFormat = document.createElement('button')
    buttonFormat.type = 'button'
    buttonFormat.className = 'jsoneditor-format'
    buttonFormat.title = translate('formatTitle')
    this.menu.appendChild(buttonFormat)
    buttonFormat.onclick = () => {
      try {
        me.format()
        me._onChange()
      } catch (err) {
        me._onError(err)
      }
    }

    // create compact button
    const buttonCompact = document.createElement('button')
    buttonCompact.type = 'button'
    buttonCompact.className = 'jsoneditor-compact'
    buttonCompact.title = translate('compactTitle')
    this.menu.appendChild(buttonCompact)
    buttonCompact.onclick = () => {
      try {
        me.compact()
        me._onChange()
      } catch (err) {
        me._onError(err)
      }
    }

    // create sort button
    if (this.options.enableSort) {
      const sort = document.createElement('button')
      sort.type = 'button'
      sort.className = 'jsoneditor-sort'
      sort.title = translate('sortTitleShort')
      sort.onclick = () => {
        me._showSortModal()
      }
      this.menu.appendChild(sort)
    }

    // create transform button
    if (this.options.enableTransform) {
      const transform = document.createElement('button')
      transform.type = 'button'
      transform.title = translate('transformTitleShort')
      transform.className = 'jsoneditor-transform'
      transform.onclick = () => {
        me._showTransformModal()
      }
      this.menu.appendChild(transform)
    }

    // create repair button
    const buttonRepair = document.createElement('button')
    buttonRepair.type = 'button'
    buttonRepair.className = 'jsoneditor-repair'
    buttonRepair.title = translate('repairTitle')
    this.menu.appendChild(buttonRepair)
    buttonRepair.onclick = () => {
      try {
        me.repair()
        me._onChange()
      } catch (err) {
        me._onError(err)
      }
    }

    // create undo/redo buttons
    if (this.mode === 'code') {
      // create undo button
      const undo = document.createElement('button')
      undo.type = 'button'
      undo.className = 'jsoneditor-undo jsoneditor-separator'
      undo.title = translate('undo')
      undo.onclick = () => {
        this.aceEditor.getSession().getUndoManager().undo()
      }
      this.menu.appendChild(undo)
      this.dom.undo = undo

      // create redo button
      const redo = document.createElement('button')
      redo.type = 'button'
      redo.className = 'jsoneditor-redo'
      redo.title = translate('redo')
      redo.onclick = () => {
        this.aceEditor.getSession().getUndoManager().redo()
      }
      this.menu.appendChild(redo)
      this.dom.redo = redo
    }

    // create mode box
    if (this.options && this.options.modes && this.options.modes.length) {
      this.modeSwitcher = new ModeSwitcher(this.menu, this.options.modes, this.options.mode, function onSwitch (mode) {
        // switch mode and restore focus
        try {
          me.setMode(mode)
          me.modeSwitcher.focus()
        } catch (err) {
          me._onError(err)
        }
      })
    }

    if (this.mode === 'code') {
      const poweredBy = document.createElement('a')
      poweredBy.appendChild(document.createTextNode('powered by ace'))
      poweredBy.href = 'https://ace.c9.io/'
      poweredBy.target = '_blank'
      poweredBy.className = 'jsoneditor-poweredBy'
      poweredBy.onclick = () => {
        // TODO: this anchor falls below the margin of the content,
        // therefore the normal a.href does not work. We use a click event
        // for now, but this should be fixed.
        window.open(poweredBy.href, poweredBy.target, 'noreferrer')
      }
      this.menu.appendChild(poweredBy)
    }
  }

  const emptyNode = {}
  const isReadOnly = (this.options.onEditable &&
  typeof (this.options.onEditable === 'function') &&
  !this.options.onEditable(emptyNode))

  this.frame.appendChild(this.content)
  this.container.appendChild(this.frame)

  if (this.mode === 'code') {
    this.editorDom = document.createElement('div')
    this.editorDom.style.height = '100%' // TODO: move to css
    this.editorDom.style.width = '100%' // TODO: move to css
    this.content.appendChild(this.editorDom)

    const aceEditor = _ace.edit(this.editorDom)
    const aceSession = aceEditor.getSession()
    aceEditor.$blockScrolling = Infinity
    aceEditor.setTheme(this.theme)
    aceEditor.setOptions({ readOnly: isReadOnly })
    aceEditor.setShowPrintMargin(false)
    aceEditor.setFontSize('14px')
    aceSession.setMode('ace/mode/json')
    aceSession.setTabSize(this.indentation)
    aceSession.setUseSoftTabs(true)
    aceSession.setUseWrapMode(true)

    // replace ace setAnnotations with custom function that also covers jsoneditor annotations
    const originalSetAnnotations = aceSession.setAnnotations
    aceSession.setAnnotations = function (annotations) {
      originalSetAnnotations.call(this, annotations && annotations.length ? annotations : me.annotations)
    }

    // disable Ctrl+L quickkey of Ace (is used by the browser to select the address bar)
    aceEditor.commands.bindKey('Ctrl-L', null)
    aceEditor.commands.bindKey('Command-L', null)

    // disable the quickkeys we want to use for Format and Compact
    aceEditor.commands.bindKey('Ctrl-\\', null)
    aceEditor.commands.bindKey('Command-\\', null)
    aceEditor.commands.bindKey('Ctrl-Shift-\\', null)
    aceEditor.commands.bindKey('Command-Shift-\\', null)

    this.aceEditor = aceEditor

    // register onchange event
    aceEditor.on('change', this._onChange.bind(this))
    aceEditor.on('changeSelection', this._onSelect.bind(this))
  } else {
    // load a plain text textarea
    const textarea = document.createElement('textarea')
    textarea.className = 'jsoneditor-text'
    textarea.spellcheck = false
    this.content.appendChild(textarea)
    this.textarea = textarea
    this.textarea.readOnly = isReadOnly

    // register onchange event
    if (this.textarea.oninput === null) {
      this.textarea.oninput = this._onChange.bind(this)
    } else {
      // oninput is undefined. For IE8-
      this.textarea.onchange = this._onChange.bind(this)
    }

    textarea.onselect = this._onSelect.bind(this)
    textarea.onmousedown = this._onMouseDown.bind(this)
    textarea.onblur = this._onBlur.bind(this)
  }

  this._updateHistoryButtons()

  this.errorTable = new ErrorTable({
    errorTableVisible: this.mode === 'text',
    onToggleVisibility: function () {
      me._validateAndCatch()
    },
    onFocusLine: function (line) {
      me.isFocused = true
      if (!isNaN(line)) {
        me.setTextSelection({ row: line, column: 1 }, { row: line, column: 1000 })
      }
    },
    onChangeHeight: function (height) {
      // TODO: change CSS to using flex box, remove setting height using JavaScript
      const statusBarHeight = me.dom.statusBar ? me.dom.statusBar.clientHeight : 0
      const totalHeight = height + statusBarHeight + 1
      me.content.style.marginBottom = (-totalHeight) + 'px'
      me.content.style.paddingBottom = totalHeight + 'px'
    }
  })
  this.frame.appendChild(this.errorTable.getErrorTable())

  if (options.statusBar) {
    addClassName(this.content, 'has-status-bar')

    this.curserInfoElements = {}
    const statusBar = document.createElement('div')
    this.dom.statusBar = statusBar
    statusBar.className = 'jsoneditor-statusbar'
    this.frame.appendChild(statusBar)

    const lnLabel = document.createElement('span')
    lnLabel.className = 'jsoneditor-curserinfo-label'
    lnLabel.innerText = 'Ln:'

    const lnVal = document.createElement('span')
    lnVal.className = 'jsoneditor-curserinfo-val'
    lnVal.innerText = '1'

    statusBar.appendChild(lnLabel)
    statusBar.appendChild(lnVal)

    const colLabel = document.createElement('span')
    colLabel.className = 'jsoneditor-curserinfo-label'
    colLabel.innerText = 'Col:'

    const colVal = document.createElement('span')
    colVal.className = 'jsoneditor-curserinfo-val'
    colVal.innerText = '1'

    statusBar.appendChild(colLabel)
    statusBar.appendChild(colVal)

    this.curserInfoElements.colVal = colVal
    this.curserInfoElements.lnVal = lnVal

    const countLabel = document.createElement('span')
    countLabel.className = 'jsoneditor-curserinfo-label'
    countLabel.innerText = 'characters selected'
    countLabel.style.display = 'none'

    const countVal = document.createElement('span')
    countVal.className = 'jsoneditor-curserinfo-count'
    countVal.innerText = '0'
    countVal.style.display = 'none'

    this.curserInfoElements.countLabel = countLabel
    this.curserInfoElements.countVal = countVal

    statusBar.appendChild(countVal)
    statusBar.appendChild(countLabel)

    statusBar.appendChild(this.errorTable.getErrorCounter())
    statusBar.appendChild(this.errorTable.getWarningIcon())
    statusBar.appendChild(this.errorTable.getErrorIcon())
  }

  this.setSchema(this.options.schema, this.options.schemaRefs)
}

textmode._onSchemaChange = function (schema, schemaRefs) {
  if (!this.aceEditor) {
    return
  }

  if (this.options.allowSchemaSuggestions && schema) {
    this.aceEditor.setOption('enableBasicAutocompletion', [new SchemaTextCompleter(schema, schemaRefs)])
    this.aceEditor.setOption('enableLiveAutocompletion', true)
  } else {
    this.aceEditor.setOption('enableBasicAutocompletion', undefined)
    this.aceEditor.setOption('enableLiveAutocompletion', false)
  }
}

/**
 * Handle a change:
 * - Validate JSON schema
 * - Send a callback to the onChange listener if provided
 * @private
 */
textmode._onChange = function () {
  if (this.onChangeDisabled) {
    return
  }

  // enable/disable undo/redo buttons
  setTimeout(() => {
    if (this._updateHistoryButtons) {
      this._updateHistoryButtons()
    }
  })

  // validate JSON schema (if configured)
  this._debouncedValidate()

  // trigger the onChange callback
  if (this.options.onChange) {
    try {
      this.options.onChange()
    } catch (err) {
      console.error('Error in onChange callback: ', err)
    }
  }

  // trigger the onChangeText callback
  if (this.options.onChangeText) {
    try {
      this.options.onChangeText(this.getText())
    } catch (err) {
      console.error('Error in onChangeText callback: ', err)
    }
  }
}

textmode._updateHistoryButtons = function () {
  if (this.aceEditor && this.dom.undo && this.dom.redo) {
    const undoManager = this.aceEditor.getSession().getUndoManager()

    if (undoManager && undoManager.hasUndo && undoManager.hasRedo) {
      this.dom.undo.disabled = !undoManager.hasUndo()
      this.dom.redo.disabled = !undoManager.hasRedo()
    }
  }
}

/**
 * Open a sort modal
 * @private
 */
textmode._showSortModal = function () {
  try {
    const me = this
    const container = this.options.modalAnchor || DEFAULT_MODAL_ANCHOR
    const json = this.get()

    function onSort (sortedBy) {
      if (Array.isArray(json)) {
        const sortedJson = sort(json, sortedBy.path, sortedBy.direction)

        me.sortedBy = sortedBy
        me.update(sortedJson)
      }

      if (isObject(json)) {
        const sortedJson = sortObjectKeys(json, sortedBy.direction)

        me.sortedBy = sortedBy
        me.update(sortedJson)
      }
    }

    showSortModal(container, json, onSort, me.sortedBy)
  } catch (err) {
    this._onError(err)
  }
}

/**
 * Open a transform modal
 * @private
 */
textmode._showTransformModal = function () {
  try {
    const { modalAnchor, createQuery, executeQuery, queryDescription } = this.options

    const json = this.get()

    showTransformModal({
      container: modalAnchor || DEFAULT_MODAL_ANCHOR,
      json,
      queryDescription, // can be undefined
      createQuery,
      executeQuery,
      onTransform: query => {
        const updatedJson = executeQuery(json, query)
        this.update(updatedJson)
      }
    })
  } catch (err) {
    this._onError(err)
  }
}

/**
 * Handle text selection
 * Calculates the cursor position and selection range and updates menu
 * @private
 */
textmode._onSelect = function () {
  this._updateCursorInfo()
  this._emitSelectionChange()
}

/**
 * Event handler for keydown. Handles shortcut keys
 * @param {Event} event
 * @private
 */
textmode._onKeyDown = function (event) {
  const keynum = event.which || event.keyCode
  let handled = false

  if (keynum === 73 && event.ctrlKey) {
    if (event.shiftKey) { // Ctrl+Shift+I
      this.compact()
      this._onChange()
    } else { // Ctrl+I
      this.format()
      this._onChange()
    }
    handled = true
  }

  if (handled) {
    event.preventDefault()
    event.stopPropagation()
  }

  this._updateCursorInfo()
  this._emitSelectionChange()
}

/**
 * Event handler for mousedown.
 * @private
 */
textmode._onMouseDown = function () {
  this._updateCursorInfo()
  this._emitSelectionChange()
}

/**
 * Event handler for blur.
 * @private
 */
textmode._onBlur = function () {
  const me = this
  // this allows to avoid blur when clicking inner elements (like the errors panel)
  // just make sure to set the isFocused to true on the inner element onclick callback
  setTimeout(() => {
    if (!me.isFocused) {
      me._updateCursorInfo()
      me._emitSelectionChange()
    }
    me.isFocused = false
  })
}

/**
 * Update the cursor info and the status bar, if presented
 */
textmode._updateCursorInfo = function () {
  const me = this
  let line, col, count

  if (this.textarea) {
    setTimeout(() => { // this to verify we get the most updated textarea cursor selection
      const selectionRange = getInputSelection(me.textarea)

      if (selectionRange.startIndex !== selectionRange.endIndex) {
        count = selectionRange.endIndex - selectionRange.startIndex
      }

      if (count && me.cursorInfo && me.cursorInfo.line === selectionRange.end.row && me.cursorInfo.column === selectionRange.end.column) {
        line = selectionRange.start.row
        col = selectionRange.start.column
      } else {
        line = selectionRange.end.row
        col = selectionRange.end.column
      }

      me.cursorInfo = {
        line,
        column: col,
        count
      }

      if (me.options.statusBar) {
        updateDisplay()
      }
    }, 0)
  } else if (this.aceEditor && this.curserInfoElements) {
    const curserPos = this.aceEditor.getCursorPosition()
    const selectedText = this.aceEditor.getSelectedText()

    line = curserPos.row + 1
    col = curserPos.column + 1
    count = selectedText.length

    me.cursorInfo = {
      line,
      column: col,
      count
    }

    if (this.options.statusBar) {
      updateDisplay()
    }
  }

  function updateDisplay () {
    if (me.curserInfoElements.countVal.innerText !== count) {
      me.curserInfoElements.countVal.innerText = count
      me.curserInfoElements.countVal.style.display = count ? 'inline' : 'none'
      me.curserInfoElements.countLabel.style.display = count ? 'inline' : 'none'
    }
    me.curserInfoElements.lnVal.innerText = line
    me.curserInfoElements.colVal.innerText = col
  }
}

/**
 * emits selection change callback, if given
 * @private
 */
textmode._emitSelectionChange = function () {
  if (this._selectionChangedHandler) {
    const currentSelection = this.getTextSelection()
    this._selectionChangedHandler(currentSelection.start, currentSelection.end, currentSelection.text)
  }
}

/**
 * refresh ERROR annotations state
 * error annotations are handled by the ace json mode (ace/mode/json)
 * validation annotations are handled by this mode
 * therefore in order to refresh we send only the annotations of error type in order to maintain its state
 * @private
 */
textmode._refreshAnnotations = function () {
  const session = this.aceEditor && this.aceEditor.getSession()
  if (session) {
    const errEnnotations = session.getAnnotations().filter(annotation => annotation.type === 'error')
    session.setAnnotations(errEnnotations)
  }
}

/**
 * Destroy the editor. Clean up DOM, event listeners, and web workers.
 */
textmode.destroy = function () {
  // remove old ace editor
  if (this.aceEditor) {
    this.aceEditor.destroy()
    this.aceEditor = null
  }

  if (this.frame && this.container && this.frame.parentNode === this.container) {
    this.container.removeChild(this.frame)
  }

  if (this.modeSwitcher) {
    this.modeSwitcher.destroy()
    this.modeSwitcher = null
  }

  this.textarea = null

  this._debouncedValidate = null

  // Removing the FocusTracker set to track the editor's focus event
  this.frameFocusTracker.destroy()
}

/**
 * Compact the code in the text editor
 */
textmode.compact = function () {
  const json = this.get()
  const text = JSON.stringify(json)
  this.updateText(text)
}

/**
 * Format the code in the text editor
 */
textmode.format = function () {
  const json = this.get()
  const text = JSON.stringify(json, null, this.indentation)
  this.updateText(text)
}

/**
 * Repair the code in the text editor
 */
textmode.repair = function () {
  const text = this.getText()
  try {
    const repairedText = jsonrepair(text)
    this.updateText(repairedText)
  } catch (err) {
    // repair was not successful, do nothing
  }
}

/**
 * Set focus to the formatter
 */
textmode.focus = function () {
  if (this.textarea) {
    this.textarea.focus()
  }
  if (this.aceEditor) {
    this.aceEditor.focus()
  }
}

/**
 * Resize the formatter
 */
textmode.resize = function () {
  if (this.aceEditor) {
    const force = false
    this.aceEditor.resize(force)
  }
}

/**
 * Set json data in the formatter
 * @param {*} json
 */
textmode.set = function (json) {
  this.setText(JSON.stringify(json, null, this.indentation))
}

/**
 * Update data. Same as calling `set` in text/code mode.
 * @param {*} json
 */
textmode.update = function (json) {
  this.updateText(JSON.stringify(json, null, this.indentation))
}

/**
 * Get json data from the formatter
 * @return {*} json
 */
textmode.get = function () {
  const text = this.getText()

  return parse(text) // this can throw an error
}

/**
 * Get the text contents of the editor
 * @return {String} jsonText
 */
textmode.getText = function () {
  if (this.textarea) {
    return this.textarea.value
  }
  if (this.aceEditor) {
    return this.aceEditor.getValue()
  }
  return ''
}

/**
 * Set the text contents of the editor and optionally clear the history
 * @param {String} jsonText
 * @param {boolean} clearHistory   Only applicable for mode 'code'
 * @private
 */
textmode._setText = function (jsonText, clearHistory) {
  const text = (this.options.escapeUnicode === true)
    ? escapeUnicodeChars(jsonText)
    : jsonText

  if (this.textarea) {
    this.textarea.value = text
  }
  if (this.aceEditor) {
    // prevent emitting onChange events while setting new text
    this.onChangeDisabled = true
    this.aceEditor.setValue(text, -1)
    this.onChangeDisabled = false

    if (clearHistory) {
      // prevent initial undo action clearing the initial contents
      const me = this
      setTimeout(() => {
        if (me.aceEditor) {
          me.aceEditor.session.getUndoManager().reset()
        }
      })
    }

    setTimeout(() => {
      if (this._updateHistoryButtons) {
        this._updateHistoryButtons()
      }
    })
  }

  // validate JSON schema
  this._debouncedValidate()
}

/**
 * Set the text contents of the editor
 * @param {String} jsonText
 */
textmode.setText = function (jsonText) {
  this._setText(jsonText, true)
}

/**
 * Update the text contents
 * @param {string} jsonText
 */
textmode.updateText = function (jsonText) {
  // don't update if there are no changes
  if (this.getText() === jsonText) {
    return
  }

  this._setText(jsonText, false)
}

/**
 * Validate current JSON object against the configured JSON schema
 * Throws an exception when no JSON schema is configured
 */
textmode.validate = function () {
  let schemaErrors = []
  let parseErrors = []
  let json
  try {
    json = this.get() // this can fail when there is no valid json

    // execute JSON schema validation (ajv)
    if (this.validateSchema) {
      const valid = this.validateSchema(json)
      if (!valid) {
        schemaErrors = this.validateSchema.errors.map(error => {
          error.type = 'validation'
          return improveSchemaError(error)
        })
      }
    }

    // execute custom validation and after than merge and render all errors
    // TODO: implement a better mechanism for only using the last validation action
    this.validationSequence = (this.validationSequence || 0) + 1
    const me = this
    const seq = this.validationSequence
    return validateCustom(json, this.options.onValidate)
      .then(customValidationErrors => {
        // only apply when there was no other validation started whilst resolving async results
        if (seq === me.validationSequence) {
          const errors = schemaErrors.concat(parseErrors).concat(customValidationErrors)
          me._renderErrors(errors)
          if (
            typeof this.options.onValidationError === 'function' &&
            isValidationErrorChanged(errors, this.lastSchemaErrors)
          ) {
            this.options.onValidationError.call(this, errors)
          }
          this.lastSchemaErrors = errors
        }

        return this.lastSchemaErrors
      })
  } catch (err) {
    if (this.getText()) {
      // try to extract the line number from the jsonlint error message
      const match = /\w*line\s*(\d+)\w*/g.exec(err.message)
      let line
      if (match) {
        line = +match[1]
      }
      parseErrors = [{
        type: 'error',
        message: err.message.replace(/\n/g, '<br>'),
        line
      }]
    }

    this._renderErrors(parseErrors)

    if (
      typeof this.options.onValidationError === 'function' &&
      isValidationErrorChanged(parseErrors, this.lastSchemaErrors)
    ) {
      this.options.onValidationError.call(this, parseErrors)
    }
    this.lastSchemaErrors = parseErrors

    return Promise.resolve(this.lastSchemaErrors)
  }
}

textmode._validateAndCatch = function () {
  this.validate().catch(err => {
    console.error('Error running validation:', err)
  })
}

textmode._renderErrors = function (errors) {
  const jsonText = this.getText()
  const errorPaths = []
  errors.reduce((acc, curr) => {
    if (typeof curr.dataPath === 'string' && acc.indexOf(curr.dataPath) === -1) {
      acc.push(curr.dataPath)
    }
    return acc
  }, errorPaths)
  const errorLocations = getPositionForPath(jsonText, errorPaths)

  // render annotations in Ace Editor (if any)
  if (this.aceEditor) {
    this.annotations = errorLocations.map(errLoc => {
      const validationErrors = errors.filter(err => err.dataPath === errLoc.path)
      const message = validationErrors.map(err => err.message).join('\n')
      if (message) {
        return {
          row: errLoc.line,
          column: errLoc.column,
          text: 'Schema validation error' + (validationErrors.length !== 1 ? 's' : '') + ': \n' + message,
          type: 'warning',
          source: 'jsoneditor'
        }
      }

      return {}
    })
    this._refreshAnnotations()
  }

  // render errors in the errors table (if any)
  this.errorTable.setErrors(errors, errorLocations)

  // update the height of the ace editor
  if (this.aceEditor) {
    const force = false
    this.aceEditor.resize(force)
  }
}

/**
 * Get the selection details
 * @returns {{start:{row:Number, column:Number},end:{row:Number, column:Number},text:String}}
 */
textmode.getTextSelection = function () {
  let selection = {}
  if (this.textarea) {
    const selectionRange = getInputSelection(this.textarea)

    if (this.cursorInfo && this.cursorInfo.line === selectionRange.end.row && this.cursorInfo.column === selectionRange.end.column) {
      // selection direction is bottom => up
      selection.start = selectionRange.end
      selection.end = selectionRange.start
    } else {
      selection = selectionRange
    }

    return {
      start: selection.start,
      end: selection.end,
      text: this.textarea.value.substring(selectionRange.startIndex, selectionRange.endIndex)
    }
  }

  if (this.aceEditor) {
    const aceSelection = this.aceEditor.getSelection()
    const selectedText = this.aceEditor.getSelectedText()
    const range = aceSelection.getRange()
    const lead = aceSelection.getSelectionLead()

    if (lead.row === range.end.row && lead.column === range.end.column) {
      selection = range
    } else {
      // selection direction is bottom => up
      selection.start = range.end
      selection.end = range.start
    }

    return {
      start: {
        row: selection.start.row + 1,
        column: selection.start.column + 1
      },
      end: {
        row: selection.end.row + 1,
        column: selection.end.column + 1
      },
      text: selectedText
    }
  }
}

/**
 * Callback registration for selection change
 * @param {selectionCallback} callback
 *
 * @callback selectionCallback
 */
textmode.onTextSelectionChange = function (callback) {
  if (typeof callback === 'function') {
    this._selectionChangedHandler = debounce(callback, this.DEBOUNCE_INTERVAL)
  }
}

/**
 * Set selection on editor's text
 * @param {{row:Number, column:Number}} startPos selection start position
 * @param {{row:Number, column:Number}} endPos selected end position
 */
textmode.setTextSelection = function (startPos, endPos) {
  if (!startPos || !endPos) return

  if (this.textarea) {
    const startIndex = getIndexForPosition(this.textarea, startPos.row, startPos.column)
    const endIndex = getIndexForPosition(this.textarea, endPos.row, endPos.column)
    if (startIndex > -1 && endIndex > -1) {
      if (this.textarea.setSelectionRange) {
        this.textarea.focus()
        this.textarea.setSelectionRange(startIndex, endIndex)
      } else if (this.textarea.createTextRange) { // IE < 9
        const range = this.textarea.createTextRange()
        range.collapse(true)
        range.moveEnd('character', endIndex)
        range.moveStart('character', startIndex)
        range.select()
      }
      const rows = (this.textarea.value.match(/\n/g) || []).length + 1
      const lineHeight = this.textarea.scrollHeight / rows
      const selectionScrollPos = (startPos.row * lineHeight)
      this.textarea.scrollTop = selectionScrollPos > this.textarea.clientHeight ? (selectionScrollPos - (this.textarea.clientHeight / 2)) : 0
    }
  } else if (this.aceEditor) {
    const range = {
      start: {
        row: startPos.row - 1,
        column: startPos.column - 1
      },
      end: {
        row: endPos.row - 1,
        column: endPos.column - 1
      }
    }
    this.aceEditor.selection.setRange(range)
    this.aceEditor.scrollToLine(startPos.row - 1, true)
  }
}

function load () {
  try {
    this.format()
  } catch (err) {
    // in case of an error, just move on, failing formatting is not a big deal
  }
}

// define modes
export const textModeMixins = [
  {
    mode: 'text',
    mixin: textmode,
    data: 'text',
    load
  },
  {
    mode: 'code',
    mixin: textmode,
    data: 'text',
    load
  }
]
