const {Patch} = require('superstring')
const {Emitter} = require('event-kit')
const Point = require('./point')
const Range = require('./range')
const DisplayMarkerLayer = require('./display-marker-layer')
const {traverse, traversal, compare, max, isEqual} = require('./point-helpers')
const isCharacterPair = require('./is-character-pair')
const ScreenLineBuilder = require('./screen-line-builder')
const {spliceArray} = require('./helpers')
const {MAX_BUILT_IN_SCOPE_ID} = require('./constants')

module.exports =
class DisplayLayer {
  constructor (id, buffer, params = {}) {
    this.id = id
    this.buffer = buffer
    this.emitter = new Emitter()
    this.screenLineBuilder = new ScreenLineBuilder(this)
    this.cachedScreenLines = []
    this.builtInScopeIdsByFlags = new Map()
    this.builtInClassNamesByScopeId = new Map()
    this.nextBuiltInScopeId = 1
    this.displayMarkerLayersById = new Map()
    this.destroyed = false
    this.changesSinceLastEvent = new Patch()

    this.invisibles = params.invisibles != null ? params.invisibles : {}
    this.tabLength = params.tabLength != null ? params.tabLength : 4
    this.softWrapColumn = params.softWrapColumn != null ? Math.max(1, params.softWrapColumn) : Infinity
    this.softWrapHangingIndent = params.softWrapHangingIndent != null ? params.softWrapHangingIndent : 0
    this.showIndentGuides = params.showIndentGuides != null ? params.showIndentGuides : false
    this.ratioForCharacter = params.ratioForCharacter != null ? params.ratioForCharacter : unitRatio
    this.isWrapBoundary = params.isWrapBoundary != null ? params.isWrapBoundary : isWordStart
    this.foldCharacter = params.foldCharacter != null ? params.foldCharacter : '⋯'
    this.atomicSoftTabs = params.atomicSoftTabs != null ? params.atomicSoftTabs : true

    this.eolInvisibles = {
      '\r': this.invisibles.cr,
      '\n': this.invisibles.eol,
      '\r\n': this.invisibles.cr + this.invisibles.eol
    }

    this.foldsMarkerLayer = params.foldsMarkerLayer || buffer.addMarkerLayer({
      maintainHistory: false,
      persistent: true,
      destroyInvalidatedMarkers: true
    })
    this.foldIdCounter = params.foldIdCounter || 1

    if (params.spatialIndex) {
      this.spatialIndex = params.spatialIndex
      this.tabCounts = params.tabCounts
      this.screenLineLengths = params.screenLineLengths
      this.rightmostScreenPosition = params.rightmostScreenPosition
      this.indexedBufferRowCount = params.indexedBufferRowCount
    } else {
      this.spatialIndex = new Patch({mergeAdjacentHunks: false})
      this.tabCounts = []
      this.screenLineLengths = []
      this.rightmostScreenPosition = Point(0, 0)
      this.indexedBufferRowCount = 0
    }

    this.bufferDidChangeLanguageMode()
  }

  static deserialize (buffer, params) {
    const foldsMarkerLayer = buffer.getMarkerLayer(params.foldsMarkerLayerId)
    return new DisplayLayer(params.id, buffer, {foldsMarkerLayer})
  }

  serialize () {
    return {
      id: this.id,
      foldsMarkerLayerId: this.foldsMarkerLayer.id,
      foldIdCounter: this.foldIdCounter
    }
  }

  reset (params) {
    if (!this.isDestroyed() && this.setParams(params)) {
      this.clearSpatialIndex()
      this.emitter.emit('did-reset')
      this.notifyObserversIfMarkerScreenPositionsChanged()
    }
  }

  copy () {
    const copyId = this.buffer.nextDisplayLayerId++
    const copy = new DisplayLayer(copyId, this.buffer, {
      foldsMarkerLayer: this.foldsMarkerLayer.copy(),
      foldIdCounter: this.foldIdCounter,
      spatialIndex: this.spatialIndex.copy(),
      tabCounts: this.tabCounts.slice(),
      screenLineLengths: this.screenLineLengths.slice(),
      rightmostScreenPosition: this.rightmostScreenPosition.copy(),
      indexedBufferRowCount: this.indexedBufferRowCount,
      invisibles: this.invisibles,
      tabLength: this.tabLength,
      softWrapColumn: this.softWrapColumn,
      softWrapHangingIndent: this.softWrapHangingIndent,
      showIndentGuides: this.showIndentGuides,
      ratioForCharacter: this.ratioForCharacter,
      isWrapBoundary: this.isWrapBoundary,
      foldCharacter: this.foldCharacter,
      atomicSoftTabs: this.atomicSoftTabs
    })
    this.buffer.displayLayers[copyId] = copy
    return copy
  }

  destroy () {
    if (this.destroyed) return
    this.destroyed = true
    this.clearSpatialIndex()
    this.foldsMarkerLayer.destroy()
    this.displayMarkerLayersById.forEach((layer) => layer.destroy())
    if (this.languageModeDisposable) this.languageModeDisposable.dispose()
    delete this.buffer.displayLayers[this.id]
  }

  isDestroyed () {
    return this.destroyed
  }

  clearSpatialIndex () {
    this.indexedBufferRowCount = 0
    this.spatialIndex.spliceOld(Point.ZERO, Point.INFINITY, Point.INFINITY)
    this.cachedScreenLines.length = 0
    this.screenLineLengths.length = 0
    this.tabCounts.length = 0
    this.rightmostScreenPosition = Point(0, 0)
  }

  doBackgroundWork (deadline) {
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), Infinity, deadline)
    return this.indexedBufferRowCount < this.buffer.getLineCount()
  }

  bufferDidChangeLanguageMode () {
    this.cachedScreenLines.length = 0
    if (this.languageModeDisposable) this.languageModeDisposable.dispose()
    this.languageModeDisposable = this.buffer.languageMode.onDidChangeHighlighting((bufferRange) => {
      bufferRange = Range.fromObject(bufferRange)
      this.populateSpatialIndexIfNeeded(bufferRange.end.row + 1, Infinity)
      const startBufferRow = this.findBoundaryPrecedingBufferRow(bufferRange.start.row)
      const endBufferRow = this.findBoundaryFollowingBufferRow(bufferRange.end.row + 1)
      const startRow = this.translateBufferPositionWithSpatialIndex(Point(startBufferRow, 0), 'backward').row
      const endRow = this.translateBufferPositionWithSpatialIndex(Point(endBufferRow, 0), 'backward').row
      const extent = Point(endRow - startRow, 0)
      spliceArray(this.cachedScreenLines, startRow, extent.row, new Array(extent.row))
      this.didChange({
        start: Point(startRow, 0),
        oldExtent: extent,
        newExtent: extent
      })
    })
  }

  addMarkerLayer (options) {
    const markerLayer = new DisplayMarkerLayer(this, this.buffer.addMarkerLayer(options), true)
    this.displayMarkerLayersById.set(markerLayer.id, markerLayer)
    return markerLayer
  }

  getMarkerLayer (id) {
    if (this.displayMarkerLayersById.has(id)) {
      return this.displayMarkerLayersById.get(id)
    } else {
      const bufferMarkerLayer = this.buffer.getMarkerLayer(id)
      if (bufferMarkerLayer) {
        const displayMarkerLayer = new DisplayMarkerLayer(this, bufferMarkerLayer, false)
        this.displayMarkerLayersById.set(id, displayMarkerLayer)
        return displayMarkerLayer
      }
    }
  }

  didDestroyMarkerLayer (id) {
    this.displayMarkerLayersById.delete(id)
  }

  onDidChange (callback) {
    return this.emitter.on('did-change', callback)
  }

  onDidReset (callback) {
    return this.emitter.on('did-reset', callback)
  }

  bufferRangeForFold (foldId) {
    return this.foldsMarkerLayer.getMarkerRange(foldId)
  }

  foldBufferRange (bufferRange) {
    bufferRange = Range.fromObject(bufferRange)
    const containingFoldMarkers = this.foldsMarkerLayer.findMarkers({containsRange: bufferRange})
    if (containingFoldMarkers.length === 0) {
      this.populateSpatialIndexIfNeeded(bufferRange.end.row + 1, Infinity)
    }
    const foldId = this.foldsMarkerLayer.markRange(bufferRange, {invalidate: 'overlap', exclusive: true}).id
    if (containingFoldMarkers.length === 0) {
      const foldStartRow = bufferRange.start.row
      const foldEndRow = bufferRange.end.row + 1
      this.didChange(this.updateSpatialIndex(foldStartRow, foldEndRow, foldEndRow, Infinity))
      this.notifyObserversIfMarkerScreenPositionsChanged()
    }
    return foldId
  }

  destroyFold (foldId) {
    const foldMarker = this.foldsMarkerLayer.getMarker(foldId)
    if (foldMarker) {
      this.destroyFoldMarkers([foldMarker])
    }
  }

  destroyAllFolds () {
    return this.destroyFoldMarkers(this.foldsMarkerLayer.findMarkers({}))
  }

  destroyFoldsIntersectingBufferRange (bufferRange) {
    return this.destroyFoldMarkers(
      this.foldsMarkerLayer.findMarkers({
        intersectsRange: this.buffer.clipRange(bufferRange)
      })
    )
  }

  destroyFoldsContainingBufferPositions (bufferPositions, excludeEndpoints) {
    const markersContainingPositions = new Set()
    for (const position of bufferPositions) {
      const clippedPosition = this.buffer.clipPosition(position)
      const foundMarkers = this.foldsMarkerLayer.findMarkers({
        containsPosition: clippedPosition
      })
      for (const marker of foundMarkers) {
        if (!excludeEndpoints || marker.getRange().containsPoint(clippedPosition, true)) {
          markersContainingPositions.add(marker)
        }
      }
    }
    const sortedMarkers = Array.from(markersContainingPositions).sort((a, b) => a.compare(b))
    return this.destroyFoldMarkers(sortedMarkers)
  }

  destroyFoldMarkers (foldMarkers) {
    const foldedRanges = []
    if (foldMarkers.length === 0) return foldedRanges

    const combinedRangeStart = foldMarkers[0].getStartPosition()
    let combinedRangeEnd = combinedRangeStart
    for (const foldMarker of foldMarkers) {
      const foldedRange = foldMarker.getRange()
      foldedRanges.push(foldedRange)
      combinedRangeEnd = max(combinedRangeEnd, foldedRange.end)
    }

    this.populateSpatialIndexIfNeeded(combinedRangeEnd.row + 1, Infinity)

    for (const foldMarker of foldMarkers) {
      foldMarker.destroy()
    }

    this.didChange(this.updateSpatialIndex(
      combinedRangeStart.row,
      combinedRangeEnd.row + 1,
      combinedRangeEnd.row + 1,
      Infinity
    ))
    this.notifyObserversIfMarkerScreenPositionsChanged()

    return foldedRanges
  }

  foldsIntersectingBufferRange (bufferRange) {
    return this.foldsMarkerLayer.findMarkers({
      intersectsRange: this.buffer.clipRange(bufferRange)
    }).map((marker) => marker.id)
  }

  translateBufferPosition (bufferPosition, options) {
    bufferPosition = this.buffer.clipPosition(bufferPosition)
    this.populateSpatialIndexIfNeeded(bufferPosition.row + 1, Infinity)

    const clipDirection = (options && options.clipDirection) || 'closest'
    const columnDelta = this.getClipColumnDelta(bufferPosition, clipDirection)
    if (columnDelta !== 0) {
      bufferPosition = Point(bufferPosition.row, bufferPosition.column + columnDelta)
    }

    let screenPosition = this.translateBufferPositionWithSpatialIndex(bufferPosition, clipDirection)
    const tabCount = this.tabCounts[screenPosition.row]
    if (tabCount > 0) {
      screenPosition = this.expandHardTabs(screenPosition, bufferPosition, tabCount)
    }

    return Point.fromObject(screenPosition)
  }

  translateBufferPositionWithSpatialIndex (bufferPosition, clipDirection) {
    let hunk = this.spatialIndex.changeForOldPosition(bufferPosition)
    if (hunk) {
      if (compare(bufferPosition, hunk.oldEnd) < 0) {
        if (compare(hunk.oldStart, bufferPosition) === 0) {
          return hunk.newStart
        } else { // hunk is a fold
          if (clipDirection === 'backward') {
            return hunk.newStart
          } else if (clipDirection === 'forward') {
            return hunk.newEnd
          } else {
            const distanceFromFoldStart = traversal(bufferPosition, hunk.oldStart)
            const distanceToFoldEnd = traversal(hunk.oldEnd, bufferPosition)
            if (compare(distanceFromFoldStart, distanceToFoldEnd) <= 0) {
              return hunk.newStart
            } else {
              return hunk.newEnd
            }
          }
        }
      } else {
        return traverse(hunk.newEnd, traversal(bufferPosition, hunk.oldEnd))
      }
    } else {
      return bufferPosition
    }
  }

  translateBufferRange (bufferRange, options) {
    bufferRange = Range.fromObject(bufferRange)
    return Range(
      this.translateBufferPosition(bufferRange.start, options),
      this.translateBufferPosition(bufferRange.end, options)
    )
  }

  translateScreenPosition (screenPosition, options) {
    screenPosition = Point.fromObject(screenPosition)
    Point.assertValid(screenPosition)
    const clipDirection = (options && options.clipDirection) || 'closest'
    const skipSoftWrapIndentation = options && options.skipSoftWrapIndentation
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), screenPosition.row + 1)
    screenPosition = this.constrainScreenPosition(screenPosition, clipDirection)
    const tabCount = this.tabCounts[screenPosition.row]
    if (tabCount > 0) {
      screenPosition = this.collapseHardTabs(screenPosition, tabCount, clipDirection)
    }
    const bufferPosition = this.translateScreenPositionWithSpatialIndex(screenPosition, clipDirection, skipSoftWrapIndentation)

    if (global.atom && bufferPosition.row >= this.buffer.getLineCount()) {
      global.atom.assert(false, 'Invalid translated buffer row', {
        bufferPosition, bufferLineCount: this.buffer.getLineCount()
      })
      return this.buffer.getEndPosition()
    }

    const columnDelta = this.getClipColumnDelta(bufferPosition, clipDirection)
    if (columnDelta !== 0) {
      return Point(bufferPosition.row, bufferPosition.column + columnDelta)
    } else {
      return Point.fromObject(bufferPosition)
    }
  }

  translateScreenPositionWithSpatialIndex (screenPosition, clipDirection, skipSoftWrapIndentation) {
    let hunk = this.spatialIndex.changeForNewPosition(screenPosition)
    if (hunk) {
      if (compare(screenPosition, hunk.newEnd) < 0) {
        if (this.isSoftWrapHunk(hunk)) {
          if ((clipDirection === 'backward' && !skipSoftWrapIndentation) ||
              (clipDirection === 'closest' && isEqual(hunk.newStart, screenPosition))) {
            return this.translateScreenPositionWithSpatialIndex(traverse(hunk.newStart, Point(0, -1)), clipDirection, skipSoftWrapIndentation)
          } else {
            return hunk.oldStart
          }
        } else { // Hunk is a fold. Since folds are 1 character on screen, we're at the start.
          return hunk.oldStart
        }
      } else {
        return traverse(hunk.oldEnd, traversal(screenPosition, hunk.newEnd))
      }
    } else {
      return screenPosition
    }
  }

  translateScreenRange (screenRange, options) {
    screenRange = Range.fromObject(screenRange)
    return Range(
      this.translateScreenPosition(screenRange.start, options),
      this.translateScreenPosition(screenRange.end, options)
    )
  }

  clipScreenPosition (screenPosition, options) {
    return this.translateBufferPosition(
      this.translateScreenPosition(screenPosition, options),
      options
    )
  }

  constrainScreenPosition (screenPosition, clipDirection) {
    let {row, column} = screenPosition

    if (row < 0) {
      return new Point(0, 0)
    }

    const maxRow = this.screenLineLengths.length - 1
    if (row > maxRow) {
      return new Point(maxRow, this.screenLineLengths[maxRow])
    }

    if (column < 0) {
      return new Point(row, 0)
    }

    const maxColumn = this.screenLineLengths[row]
    if (column > maxColumn) {
      if (clipDirection === 'forward' && row < maxRow) {
        return new Point(row + 1, 0)
      } else {
        return new Point(row, maxColumn)
      }
    }

    return screenPosition
  }

  expandHardTabs (targetScreenPosition, targetBufferPosition, tabCount) {
    const screenRowStart = Point(targetScreenPosition.row, 0)
    const hunks = this.spatialIndex.getChangesInNewRange(screenRowStart, targetScreenPosition)
    let hunkIndex = 0
    let unexpandedScreenColumn = 0
    let expandedScreenColumn = 0
    let {row: bufferRow, column: bufferColumn} = this.translateScreenPositionWithSpatialIndex(screenRowStart)
    let bufferLine = this.buffer.lineForRow(bufferRow)

    while (tabCount > 0) {
      if (unexpandedScreenColumn === targetScreenPosition.column) {
        break
      }

      let nextHunk = hunks[hunkIndex]
      if (nextHunk && nextHunk.oldStart.row === bufferRow && nextHunk.oldStart.column === bufferColumn) {
        if (this.isSoftWrapHunk(nextHunk)) {
          if (hunkIndex !== 0) throw new Error('Unexpected soft wrap hunk')
          unexpandedScreenColumn = hunks[0].newEnd.column
          expandedScreenColumn = unexpandedScreenColumn
        } else {
          ({row: bufferRow, column: bufferColumn} = nextHunk.oldEnd)
          bufferLine = this.buffer.lineForRow(bufferRow)
          unexpandedScreenColumn++
          expandedScreenColumn++
        }

        hunkIndex++
        continue
      }

      if (bufferLine[bufferColumn] === '\t') {
        expandedScreenColumn += (this.tabLength - (expandedScreenColumn % this.tabLength))
        tabCount--
      } else {
        expandedScreenColumn++
      }
      unexpandedScreenColumn++
      bufferColumn++
    }

    expandedScreenColumn += targetScreenPosition.column - unexpandedScreenColumn
    if (expandedScreenColumn === targetScreenPosition.column) {
      return targetScreenPosition
    } else {
      return Point(targetScreenPosition.row, expandedScreenColumn)
    }
  }

  collapseHardTabs (targetScreenPosition, tabCount, clipDirection) {
    const screenRowStart = Point(targetScreenPosition.row, 0)
    const screenRowEnd = Point(targetScreenPosition.row, this.screenLineLengths[targetScreenPosition.row])

    const hunks = this.spatialIndex.getChangesInNewRange(screenRowStart, screenRowEnd)
    let hunkIndex = 0
    let unexpandedScreenColumn = 0
    let expandedScreenColumn = 0
    let {row: bufferRow, column: bufferColumn} = this.translateScreenPositionWithSpatialIndex(screenRowStart)
    let bufferLine = this.buffer.lineForRow(bufferRow)

    while (tabCount > 0) {
      if (expandedScreenColumn === targetScreenPosition.column) {
        break
      }

      let nextHunk = hunks[hunkIndex]
      if (nextHunk && nextHunk.oldStart.row === bufferRow && nextHunk.oldStart.column === bufferColumn) {
        if (this.isSoftWrapHunk(nextHunk)) {
          if (hunkIndex !== 0) throw new Error('Unexpected soft wrap hunk')
          unexpandedScreenColumn = Math.min(targetScreenPosition.column, nextHunk.newEnd.column)
          expandedScreenColumn = unexpandedScreenColumn
        } else {
          ({row: bufferRow, column: bufferColumn} = nextHunk.oldEnd)
          bufferLine = this.buffer.lineForRow(bufferRow)
          unexpandedScreenColumn++
          expandedScreenColumn++
        }
        hunkIndex++
        continue
      }

      if (bufferLine[bufferColumn] === '\t') {
        const nextTabStopColumn = expandedScreenColumn + this.tabLength - (expandedScreenColumn % this.tabLength)
        if (nextTabStopColumn > targetScreenPosition.column) {
          if (clipDirection === 'backward') {
            return Point(targetScreenPosition.row, unexpandedScreenColumn)
          } else if (clipDirection === 'forward') {
            return Point(targetScreenPosition.row, unexpandedScreenColumn + 1)
          } else {
            if (targetScreenPosition.column > Math.ceil((nextTabStopColumn + expandedScreenColumn) / 2)) {
              return Point(targetScreenPosition.row, unexpandedScreenColumn + 1)
            } else {
              return Point(targetScreenPosition.row, unexpandedScreenColumn)
            }
          }
        }
        expandedScreenColumn = nextTabStopColumn
        tabCount--
      } else {
        expandedScreenColumn++
      }
      unexpandedScreenColumn++
      bufferColumn++
    }

    unexpandedScreenColumn += targetScreenPosition.column - expandedScreenColumn
    if (unexpandedScreenColumn === targetScreenPosition.column) {
      return targetScreenPosition
    } else {
      return Point(targetScreenPosition.row, unexpandedScreenColumn)
    }
  }

  getClipColumnDelta (bufferPosition, clipDirection) {
    var {row, column} = bufferPosition

    // Treat paired unicode characters as atomic...
    var character = this.buffer.getCharacterAtPosition(bufferPosition)
    var previousCharacter = this.buffer.getCharacterAtPosition([row, column - 1])
    if (previousCharacter && character && isCharacterPair(previousCharacter, character)) {
      if (clipDirection === 'closest' || clipDirection === 'backward') {
        return -1
      } else {
        return 1
      }
    }

    // Clip atomic soft tabs...

    if (!this.atomicSoftTabs) return 0

    if (column * this.ratioForCharacter(' ') > this.softWrapColumn) {
      return 0
    }

    for (let position = {row, column}; position.column >= 0; position.column--) {
      if (this.buffer.getCharacterAtPosition(position) !== ' ') return 0
    }

    var previousTabStop = column - (column % this.tabLength)
    if (column === previousTabStop) return 0
    var nextTabStop = previousTabStop + this.tabLength

    // If there is a non-whitespace character before the next tab stop,
    // don't this whitespace as a soft tab
    for (let position = {row, column}; position.column < nextTabStop; position.column++) {
      if (this.buffer.getCharacterAtPosition(position) !== ' ') return 0
    }

    var clippedColumn
    if (clipDirection === 'closest') {
      if (column - previousTabStop > this.tabLength / 2) {
        clippedColumn = nextTabStop
      } else {
        clippedColumn = previousTabStop
      }
    } else if (clipDirection === 'backward') {
      clippedColumn = previousTabStop
    } else if (clipDirection === 'forward') {
      clippedColumn = nextTabStop
    }

    return clippedColumn - column
  }

  getText (startRow, endRow) {
    return this.getScreenLines(startRow, endRow).map((line) => line.lineText).join('\n')
  }

  lineLengthForScreenRow (screenRow) {
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), screenRow + 1)
    return this.screenLineLengths[screenRow]
  }

  getLastScreenRow () {
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), Infinity)
    return this.screenLineLengths.length - 1
  }

  getScreenLineCount () {
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), Infinity)
    return this.screenLineLengths.length
  }

  getApproximateScreenLineCount () {
    if (this.indexedBufferRowCount > 0) {
      return Math.floor(this.buffer.getLineCount() * this.screenLineLengths.length / this.indexedBufferRowCount)
    } else {
      return this.buffer.getLineCount()
    }
  }

  getRightmostScreenPosition () {
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), Infinity)
    return this.rightmostScreenPosition
  }

  getApproximateRightmostScreenPosition () {
    return this.rightmostScreenPosition
  }

  getScreenLine (screenRow) {
    return this.cachedScreenLines[screenRow] || this.getScreenLines(screenRow, screenRow + 1)[0]
  }

  getScreenLines (screenStartRow = 0, screenEndRow = this.getScreenLineCount()) {
    return this.screenLineBuilder.buildScreenLines(screenStartRow, screenEndRow)
  }

  bufferRowsForScreenRows (startRow, endRow) {
    this.populateSpatialIndexIfNeeded(this.buffer.getLineCount(), endRow)

    const startPosition = Point(startRow, 0)
    const bufferRows = []
    let lastScreenRow = startRow
    let lastBufferRow = this.translateScreenPositionWithSpatialIndex(startPosition).row
    const hunks = this.spatialIndex.getChangesInNewRange(startPosition, Point(endRow, 0))
    for (let i = 0; i < hunks.length; i++) {
      const hunk = hunks[i]
      while (lastScreenRow <= hunk.newStart.row) {
        bufferRows.push(lastBufferRow)
        lastScreenRow++
        lastBufferRow++
      }

      lastBufferRow = this.isSoftWrapHunk(hunk) ? hunk.oldEnd.row : hunk.oldEnd.row + 1
    }

    while (lastScreenRow < endRow) {
      bufferRows.push(lastBufferRow)
      lastScreenRow++
      lastBufferRow++
    }

    return bufferRows
  }

  leadingWhitespaceLengthForSurroundingLines (startBufferRow) {
    let length = 0
    for (let bufferRow = startBufferRow - 1; bufferRow >= 0; bufferRow--) {
      const line = this.buffer.lineForRow(bufferRow)
      if (line.length > 0) {
        length = this.leadingWhitespaceLengthForNonEmptyLine(line)
        break
      }
    }

    const lineCount = this.buffer.getLineCount()
    for (let bufferRow = startBufferRow + 1; bufferRow < lineCount; bufferRow++) {
      const line = this.buffer.lineForRow(bufferRow)
      if (line.length > 0) {
        length = Math.max(length, this.leadingWhitespaceLengthForNonEmptyLine(line))
        break
      }
    }

    return length
  }

  leadingWhitespaceLengthForNonEmptyLine (line) {
    let length = 0
    for (let i = 0; i < line.length; i++) {
      const character = line[i]
      if (character === ' ') {
        length++
      } else if (character === '\t') {
        length += this.tabLength - (length % this.tabLength)
      } else {
        break
      }
    }
    return length
  }

  findTrailingWhitespaceStartColumn (bufferRow) {
    let position
    for (position = {row: bufferRow, column: this.buffer.lineLengthForRow(bufferRow) - 1}; position.column >= 0; position.column--) {
      const previousCharacter = this.buffer.getCharacterAtPosition(position)
      if (previousCharacter !== ' ' && previousCharacter !== '\t') {
        break
      }
    }
    return position.column + 1
  }

  registerBuiltInScope (flags, className) {
    if (this.nextBuiltInScopeId > MAX_BUILT_IN_SCOPE_ID) {
      throw new Error('Built in scope ids exhausted')
    }

    let scopeId
    if (className.length > 0) {
      scopeId = this.nextBuiltInScopeId += 2
      this.builtInClassNamesByScopeId.set(scopeId, className)
    } else {
      scopeId = 0
    }
    this.builtInScopeIdsByFlags.set(flags, scopeId)
    return scopeId
  }

  getBuiltInScopeId (flags) {
    if (this.builtInScopeIdsByFlags.has(flags)) {
      return this.builtInScopeIdsByFlags.get(flags)
    } else {
      return -1
    }
  }

  classNameForScopeId (scopeId) {
    if (scopeId <= MAX_BUILT_IN_SCOPE_ID) {
      return this.builtInClassNamesByScopeId.get(scopeId)
    } else {
      return this.buffer.languageMode.classNameForScopeId(scopeId)
    }
  }

  scopeIdForTag (tag) {
    if (this.isCloseTag(tag)) tag++
    return -tag
  }

  classNameForTag (tag) {
    return this.classNameForScopeId(this.scopeIdForTag(tag))
  }

  openTagForScopeId (scopeId) {
    return -scopeId
  }

  closeTagForScopeId (scopeId) {
    return -scopeId - 1
  }

  isOpenTag (tag) {
    return tag < 0 && (tag & 1) === 1
  }

  isCloseTag (tag) {
    return tag < 0 && (tag & 1) === 0
  }

  bufferWillChange (change) {
    const lineCount = this.buffer.getLineCount()
    let endRow = change.oldRange.end.row
    while (endRow + 1 < lineCount && this.buffer.lineLengthForRow(endRow + 1) === 0) {
      endRow++
    }
    this.populateSpatialIndexIfNeeded(endRow + 1, Infinity)
  }

  bufferDidChange ({oldRange, newRange}) {
    let startRow = oldRange.start.row
    let oldEndRow = oldRange.end.row
    let newEndRow = newRange.end.row

    // Indent guides on sequences of blank lines are affected by the content of
    // adjacent lines.
    if (this.showIndentGuides) {
      while (startRow > 0) {
        if (this.buffer.lineLengthForRow(startRow - 1) > 0) break
        startRow--
      }

      while (newEndRow < this.buffer.getLastRow()) {
        if (this.buffer.lineLengthForRow(newEndRow + 1) > 0) break
        oldEndRow++
        newEndRow++
      }
    }

    this.indexedBufferRowCount += newEndRow - oldEndRow
    this.didChange(this.updateSpatialIndex(startRow, oldEndRow + 1, newEndRow + 1, Infinity))
  }

  didChange ({start, oldExtent, newExtent}) {
    this.changesSinceLastEvent.splice(start, oldExtent, newExtent)
    if (this.buffer.transactCallDepth === 0) this.emitDeferredChangeEvents()
  }

  emitDeferredChangeEvents () {
    if (this.changesSinceLastEvent.getChangeCount() > 0) {
      this.emitter.emit('did-change', this.changesSinceLastEvent.getChanges().map((change) => {
        return {
          oldRange: new Range(change.oldStart, change.oldEnd),
          newRange: new Range(change.newStart, change.newEnd)
        }
      }))
      this.changesSinceLastEvent = new Patch()
    }
  }

  notifyObserversIfMarkerScreenPositionsChanged () {
    this.displayMarkerLayersById.forEach((layer) => {
      layer.notifyObserversIfMarkerScreenPositionsChanged()
    })
  }

  updateSpatialIndex (startBufferRow, oldEndBufferRow, newEndBufferRow, endScreenRow, deadline = NullDeadline) {
    const originalOldEndBufferRow = oldEndBufferRow
    startBufferRow = this.findBoundaryPrecedingBufferRow(startBufferRow)
    oldEndBufferRow = this.findBoundaryFollowingBufferRow(oldEndBufferRow)
    newEndBufferRow += (oldEndBufferRow - originalOldEndBufferRow)

    const startScreenRow = this.translateBufferPositionWithSpatialIndex({row: startBufferRow, column: 0}, 'backward').row
    const oldEndScreenRow = this.translateBufferPositionWithSpatialIndex({row: oldEndBufferRow, column: 0}, 'backward').row
    this.spatialIndex.spliceOld(
      {row: startBufferRow, column: 0},
      {row: oldEndBufferRow - startBufferRow, column: 0},
      {row: newEndBufferRow - startBufferRow, column: 0}
    )

    const folds = this.computeFoldsInBufferRowRange(startBufferRow, newEndBufferRow)

    const insertedScreenLineLengths = []
    const insertedTabCounts = []
    const currentScreenLineTabColumns = []
    let rightmostInsertedScreenPosition = Point(0, -1)
    let bufferRow = startBufferRow
    let screenRow = startScreenRow
    let bufferColumn = 0
    let unexpandedScreenColumn = 0
    let expandedScreenColumn = 0

    while (true) {
      if (bufferRow >= newEndBufferRow) break
      if (screenRow >= endScreenRow && bufferColumn === 0) break
      if (deadline.timeRemaining() < 2) break
      let bufferLine = this.buffer.lineForRow(bufferRow)
      if (bufferLine == null) break
      let bufferLineLength = bufferLine.length
      currentScreenLineTabColumns.length = 0
      let screenLineWidth = 0
      let lastWrapBoundaryUnexpandedScreenColumn = 0
      let lastWrapBoundaryExpandedScreenColumn = 0
      let lastWrapBoundaryScreenLineWidth = 0
      let firstNonWhitespaceScreenColumn = -1

      while (bufferColumn <= bufferLineLength) {
        const foldEnd = folds[bufferRow] && folds[bufferRow][bufferColumn]
        const previousCharacter = bufferLine[bufferColumn - 1]
        const character = foldEnd ? this.foldCharacter : bufferLine[bufferColumn]

        // Are we in leading whitespace? If yes, record the *end* of the leading
        // whitespace if we've reached a non whitespace character. If no, record
        // the current column if it is a viable soft wrap boundary.
        if (firstNonWhitespaceScreenColumn < 0) {
          if (character !== ' ' && character !== '\t') {
            firstNonWhitespaceScreenColumn = expandedScreenColumn
          }
        } else {
          if (previousCharacter &&
              character &&
              this.isWrapBoundary(previousCharacter, character)) {
            lastWrapBoundaryUnexpandedScreenColumn = unexpandedScreenColumn
            lastWrapBoundaryExpandedScreenColumn = expandedScreenColumn
            lastWrapBoundaryScreenLineWidth = screenLineWidth
          }
        }

        // Determine the on-screen width of the character for soft-wrap calculations
        let characterWidth
        if (character === '\t') {
          const distanceToNextTabStop = this.tabLength - (expandedScreenColumn % this.tabLength)
          characterWidth = this.ratioForCharacter(' ') * distanceToNextTabStop
        } else if (character) {
          characterWidth = this.ratioForCharacter(character)
        } else {
          characterWidth = 0
        }

        const insertSoftLineBreak =
          screenLineWidth > 0 && characterWidth > 0 &&
          screenLineWidth + characterWidth > this.softWrapColumn &&
          previousCharacter && character &&
          !isCharacterPair(previousCharacter, character)

        if (insertSoftLineBreak) {
          let indentLength = (firstNonWhitespaceScreenColumn < this.softWrapColumn)
            ? Math.max(0, firstNonWhitespaceScreenColumn)
            : 0
          if (indentLength + this.softWrapHangingIndent < this.softWrapColumn) {
            indentLength += this.softWrapHangingIndent
          }

          const unexpandedWrapColumn = lastWrapBoundaryUnexpandedScreenColumn || unexpandedScreenColumn
          const expandedWrapColumn = lastWrapBoundaryExpandedScreenColumn || expandedScreenColumn
          const wrapWidth = lastWrapBoundaryScreenLineWidth || screenLineWidth
          this.spatialIndex.splice(
            Point(screenRow, unexpandedWrapColumn),
            Point.ZERO,
            Point(1, indentLength)
          )

          insertedScreenLineLengths.push(expandedWrapColumn)
          if (expandedWrapColumn > rightmostInsertedScreenPosition.column) {
            rightmostInsertedScreenPosition.row = screenRow
            rightmostInsertedScreenPosition.column = expandedWrapColumn
          }
          screenRow++

          // To determine the expanded screen column following the wrap, we need
          // to re-expand each tab following the wrap boundary, because tabs may
          // take on different lengths due to starting at different screen columns.
          let unexpandedScreenColumnAfterLastTab = indentLength
          let expandedScreenColumnAfterLastTab = indentLength
          let tabCountPrecedingWrap = 0
          for (let i = 0; i < currentScreenLineTabColumns.length; i++) {
            const tabColumn = currentScreenLineTabColumns[i]
            if (tabColumn < unexpandedWrapColumn) {
              tabCountPrecedingWrap++
            } else {
              const tabColumnAfterWrap = indentLength + tabColumn - unexpandedWrapColumn
              expandedScreenColumnAfterLastTab += (tabColumnAfterWrap - unexpandedScreenColumnAfterLastTab)
              expandedScreenColumnAfterLastTab += this.tabLength - (expandedScreenColumnAfterLastTab % this.tabLength)
              unexpandedScreenColumnAfterLastTab = tabColumnAfterWrap + 1
              currentScreenLineTabColumns[i - tabCountPrecedingWrap] = tabColumnAfterWrap
            }
          }
          insertedTabCounts.push(tabCountPrecedingWrap)
          currentScreenLineTabColumns.length -= tabCountPrecedingWrap

          unexpandedScreenColumn = unexpandedScreenColumn - unexpandedWrapColumn + indentLength
          expandedScreenColumn = expandedScreenColumnAfterLastTab + unexpandedScreenColumn - unexpandedScreenColumnAfterLastTab
          screenLineWidth = (indentLength * this.ratioForCharacter(' ')) + (screenLineWidth - wrapWidth)

          lastWrapBoundaryUnexpandedScreenColumn = 0
          lastWrapBoundaryExpandedScreenColumn = 0
          lastWrapBoundaryScreenLineWidth = 0
        }

        // If there is a fold at this position, splice it into the spatial index
        // and jump to the end of the fold.
        if (foldEnd) {
          this.spatialIndex.splice(
            {row: screenRow, column: unexpandedScreenColumn},
            traversal(foldEnd, {row: bufferRow, column: bufferColumn}),
            {row: 0, column: 1}
          )
          unexpandedScreenColumn++
          expandedScreenColumn++
          screenLineWidth += characterWidth
          bufferRow = foldEnd.row
          bufferColumn = foldEnd.column
          bufferLine = this.buffer.lineForRow(bufferRow)
          bufferLineLength = bufferLine.length
        } else {
          // If there is no fold at this position, check if we need to handle
          // a hard tab at this position and advance by a single buffer column.
          if (character === '\t') {
            currentScreenLineTabColumns.push(unexpandedScreenColumn)
            const distanceToNextTabStop = this.tabLength - (expandedScreenColumn % this.tabLength)
            expandedScreenColumn += distanceToNextTabStop
            screenLineWidth += distanceToNextTabStop * this.ratioForCharacter(' ')
          } else {
            expandedScreenColumn++
            screenLineWidth += characterWidth
          }
          unexpandedScreenColumn++
          bufferColumn++
        }
      }

      expandedScreenColumn--
      insertedScreenLineLengths.push(expandedScreenColumn)
      insertedTabCounts.push(currentScreenLineTabColumns.length)
      if (expandedScreenColumn > rightmostInsertedScreenPosition.column) {
        rightmostInsertedScreenPosition.row = screenRow
        rightmostInsertedScreenPosition.column = expandedScreenColumn
      }

      bufferRow++
      bufferColumn = 0

      screenRow++
      unexpandedScreenColumn = 0
      expandedScreenColumn = 0
    }

    if (bufferRow > this.indexedBufferRowCount) {
      this.indexedBufferRowCount = bufferRow
      if (bufferRow === this.buffer.getLineCount()) {
        this.spatialIndex.rebalance()
      }
    }

    const oldScreenRowCount = oldEndScreenRow - startScreenRow
    spliceArray(
      this.screenLineLengths,
      startScreenRow,
      oldScreenRowCount,
      insertedScreenLineLengths
    )
    spliceArray(
      this.tabCounts,
      startScreenRow,
      oldScreenRowCount,
      insertedTabCounts
    )

    const lastRemovedScreenRow = startScreenRow + oldScreenRowCount
    if (rightmostInsertedScreenPosition.column > this.rightmostScreenPosition.column) {
      this.rightmostScreenPosition = rightmostInsertedScreenPosition
    } else if (lastRemovedScreenRow < this.rightmostScreenPosition.row) {
      this.rightmostScreenPosition.row += insertedScreenLineLengths.length - oldScreenRowCount
    } else if (startScreenRow <= this.rightmostScreenPosition.row) {
      this.rightmostScreenPosition = Point(0, 0)
      for (let row = 0, rowCount = this.screenLineLengths.length; row < rowCount; row++) {
        if (this.screenLineLengths[row] > this.rightmostScreenPosition.column) {
          this.rightmostScreenPosition.row = row
          this.rightmostScreenPosition.column = this.screenLineLengths[row]
        }
      }
    }

    spliceArray(
      this.cachedScreenLines,
      startScreenRow,
      oldScreenRowCount,
      new Array(insertedScreenLineLengths.length)
    )

    return {
      start: Point(startScreenRow, 0),
      oldExtent: Point(oldScreenRowCount, 0),
      newExtent: Point(insertedScreenLineLengths.length, 0)
    }
  }

  populateSpatialIndexIfNeeded (endBufferRow, endScreenRow, deadline = NullDeadline) {
    endBufferRow = Math.min(this.buffer.getLineCount(), endBufferRow)
    if (endBufferRow > this.indexedBufferRowCount && endScreenRow > this.screenLineLengths.length) {
      this.updateSpatialIndex(
        this.indexedBufferRowCount,
        endBufferRow,
        endBufferRow,
        endScreenRow,
        deadline
      )
    }
  }

  findBoundaryPrecedingBufferRow (bufferRow) {
    while (true) {
      if (bufferRow === 0) return 0
      let screenPosition = this.translateBufferPositionWithSpatialIndex(Point(bufferRow, 0), 'backward')
      let bufferPosition = this.translateScreenPositionWithSpatialIndex(Point(screenPosition.row, 0), 'backward', false)
      if (screenPosition.column === 0 && bufferPosition.column === 0) {
        return bufferPosition.row
      } else {
        bufferRow = bufferPosition.row
      }
    }
  }

  findBoundaryFollowingBufferRow (bufferRow) {
    while (true) {
      let screenPosition = this.translateBufferPositionWithSpatialIndex(Point(bufferRow, 0), 'forward')
      if (screenPosition.column === 0) {
        return bufferRow
      } else {
        const endOfScreenRow = Point(
          screenPosition.row,
          this.screenLineLengths[screenPosition.row]
        )
        bufferRow = this.translateScreenPositionWithSpatialIndex(endOfScreenRow, 'forward', false).row + 1
      }
    }
  }

  findBoundaryFollowingScreenRow (screenRow) {
    while (true) {
      let bufferPosition = this.translateScreenPositionWithSpatialIndex(Point(screenRow, 0), 'forward')
      if (bufferPosition.column === 0) {
        return [bufferPosition.row, screenRow]
      } else {
        const endOfBufferRow = Point(
          bufferPosition.row,
          this.buffer.lineLengthForRow(bufferPosition.row)
        )
        screenRow = this.translateBufferPositionWithSpatialIndex(endOfBufferRow, 'forward').row + 1
      }
    }
  }

  // Returns a map describing fold starts and ends, structured as
  // fold start row -> fold start column -> fold end point
  computeFoldsInBufferRowRange (startBufferRow, endBufferRow) {
    const folds = {}
    const foldMarkers = this.foldsMarkerLayer.findMarkers({
      intersectsRowRange: [startBufferRow, endBufferRow - 1]
    })

    // If the given buffer range exceeds the indexed range, we need to ensure
    // we consider any folds that intersect the combined row range of the
    // initially-queried folds, since we couldn't use the index to expand the
    // row range to account for these extra folds ahead of time.
    if (endBufferRow >= this.indexedBufferRowCount) {
      for (let i = 0; i < foldMarkers.length; i++) {
        const marker = foldMarkers[i]
        const nextMarker = foldMarkers[i + 1]
        if (marker.getEndPosition().row >= endBufferRow &&
            (!nextMarker || nextMarker.getEndPosition().row < marker.getEndPosition().row)) {
          const intersectingMarkers = this.foldsMarkerLayer.findMarkers({
            intersectsRow: marker.getEndPosition().row
          })
          endBufferRow = marker.getEndPosition().row + 1
          foldMarkers.splice(i, foldMarkers.length - i, ...intersectingMarkers)
        }
      }
    }

    for (let i = 0; i < foldMarkers.length; i++) {
      const foldStart = foldMarkers[i].getStartPosition()
      let foldEnd = foldMarkers[i].getEndPosition()

      // Merge overlapping folds
      while (i < foldMarkers.length - 1) {
        const nextFoldMarker = foldMarkers[i + 1]
        if (compare(nextFoldMarker.getStartPosition(), foldEnd) < 0) {
          if (compare(foldEnd, nextFoldMarker.getEndPosition()) < 0) {
            foldEnd = nextFoldMarker.getEndPosition()
          }
          i++
        } else {
          break
        }
      }

      // Add non-empty folds to the returned result
      if (compare(foldStart, foldEnd) < 0) {
        if (!folds[foldStart.row]) folds[foldStart.row] = {}
        folds[foldStart.row][foldStart.column] = foldEnd
      }
    }

    return folds
  }

  setParams (params) {
    let paramsChanged = false
    if (params.hasOwnProperty('tabLength') && params.tabLength !== this.tabLength) {
      paramsChanged = true
      this.tabLength = params.tabLength
    }
    if (params.hasOwnProperty('invisibles') && !invisiblesEqual(params.invisibles, this.invisibles)) {
      paramsChanged = true
      this.invisibles = params.invisibles
      this.eolInvisibles = {
        '\r': this.invisibles.cr,
        '\n': this.invisibles.eol,
        '\r\n': this.invisibles.cr + this.invisibles.eol
      }
    }
    if (params.hasOwnProperty('showIndentGuides') && params.showIndentGuides !== this.showIndentGuides) {
      paramsChanged = true
      this.showIndentGuides = params.showIndentGuides
    }
    if (params.hasOwnProperty('softWrapColumn')) {
      let softWrapColumn = params.softWrapColumn != null
        ? Math.max(1, params.softWrapColumn)
        : Infinity
      if (softWrapColumn !== this.softWrapColumn) {
        paramsChanged = true
        this.softWrapColumn = softWrapColumn
      }
    }
    if (params.hasOwnProperty('softWrapHangingIndent') && params.softWrapHangingIndent !== this.softWrapHangingIndent) {
      paramsChanged = true
      this.softWrapHangingIndent = params.softWrapHangingIndent
    }
    if (params.hasOwnProperty('ratioForCharacter') && params.ratioForCharacter !== this.ratioForCharacter) {
      paramsChanged = true
      this.ratioForCharacter = params.ratioForCharacter
    }
    if (params.hasOwnProperty('isWrapBoundary') && params.isWrapBoundary !== this.isWrapBoundary) {
      paramsChanged = true
      this.isWrapBoundary = params.isWrapBoundary
    }
    if (params.hasOwnProperty('foldCharacter') && params.foldCharacter !== this.foldCharacter) {
      paramsChanged = true
      this.foldCharacter = params.foldCharacter
    }
    if (params.hasOwnProperty('atomicSoftTabs') && params.atomicSoftTabs !== this.atomicSoftTabs) {
      paramsChanged = true
      this.atomicSoftTabs = params.atomicSoftTabs
    }
    return paramsChanged
  }

  isSoftWrapHunk (hunk) {
    return isEqual(hunk.oldStart, hunk.oldEnd)
  }
}

function invisiblesEqual (left, right) {
  let leftKeys = Object.keys(left)
  let rightKeys = Object.keys(right)
  if (leftKeys.length !== rightKeys.length) return false
  for (let key of leftKeys) {
    if (left[key] !== right[key]) return false
  }
  return true
}

function isWordStart (previousCharacter, character) {
  return (previousCharacter === ' ' || previousCharacter === '\t') &&
    (character !== ' ' && character !== '\t')
}

function unitRatio () {
  return 1
}

const NullDeadline = {
  didTimeout: false,
  timeRemaining () { return Infinity }
}
