package ocelot.desktop.ui.widget

import ocelot.desktop.ColorScheme
import ocelot.desktop.color.Color
import ocelot.desktop.geometry.Size2D
import ocelot.desktop.graphics.{Font, Graphics, IconSource}
import ocelot.desktop.ui.UiHandler
import ocelot.desktop.ui.event.handlers.MouseHandler
import ocelot.desktop.ui.event.sources.KeyEvents
import ocelot.desktop.ui.event.{DragEvent, KeyEvent, MouseEvent}
import ocelot.desktop.ui.widget.TextInput.{Cursor, Selection, Text, isPunctuation, isWordCharacter}
import ocelot.desktop.ui.widget.contextmenu.{ContextMenu, ContextMenuEntry}
import ocelot.desktop.ui.widget.traits.HoverAnimation
import ocelot.desktop.util.NumberUtils.ExtendedInt
import ocelot.desktop.util.animation.ColorAnimation
import ocelot.desktop.util.{BaseWatcher, DrawUtils, Register, Watcher}
import org.lwjgl.input.Keyboard

class TextInput(val initialText: String = "") extends Widget with MouseHandler with HoverAnimation {
  private val CursorBlinkTime = 2f
  private val PlaceholderForegroundColor: Color = ColorScheme("TextInputForegroundDisabled")
  private val BackgroundSelectedColor: Color = ColorScheme("TextInputBackgroundSelected")
  private val ForegroundSelectedColor: Color = ColorScheme("TextInputForegroundSelected")
  override protected val HoverAnimationColorDefault: Color = ColorScheme("TextInputBackground")
  override protected val HoverAnimationColorActive: Color = ColorScheme("TextInputBackgroundActive")

  // model
  private val _text: Text = new Text(initialText.codePoints().toArray)
  private val cursor: Cursor = new Cursor()
  private val selectionWatcher = Watcher[Option[Selection]](None)

  // updated after all events are processed so that event handlers can refer to the previous position.
  private val prevCursorPosition = Register.sampling(cursor.position)

  // view
  protected var isFocused = false
  private var scroll = 0f
  private var blinkTimer = 0f
  private var cursorOffset = 0f
  private var selectionOffsets: Option[(Int, Int)] = None

  private var lastLeftClickInfo = Option.empty[MouseHandler.ClickInfo]

  private val enabledRegister = Register.sampling(enabled)

  private def selection: Option[Selection] = selectionWatcher.value
  private def selection_=(newValue: Option[Selection]): Unit = {
    selectionWatcher.value = newValue
  }

  cursor.onChange = { position =>
    cursorOffset = charsWidth(_text.chars, 0, position)
    blinkTimer = 0
    adjustScroll()
  }

  selectionWatcher.onChange = { newValue =>
    selectionOffsets = newValue.map {
      case Selection.Ordered(start, end) =>
        (
          charsWidth(_text.chars, 0, start),
          charsWidth(_text.chars, 0, end),
        )
    }
  }

  private val foregroundAnimation = new ColorAnimation(targetForegroundColor, 7f)
  private val borderAnimation = new ColorAnimation(targetBorderColor, 7f)

  // public API
  // -------------------------------------------------------------------------------------------------------------------
  def onInput(text: String): Unit = {}

  def onConfirm(): Unit = {
    unfocus()
  }

  def validator(text: String): Boolean = true
  final def isInputValid: Boolean = validator(text)

  def text: String = new String(_text.chars, 0, _text.chars.length)
  def text_=(value: String): Unit = {
    _text.chars = value.codePoints().toArray
    selection = None

    val desiredPosition = cursor.desiredPosition
    cursor.position = desiredPosition max 0 min _text.chars.length
    cursor.desiredPosition = desiredPosition
  }

  private def selectedText: String = selection match {
    case Some(Selection.Ordered(start, end)) => new String(_text.chars, start, end - start)
    case None => ""
  }

  protected var placeholder: Array[Int] = Array.empty
  def placeholder_=(value: String): Unit = placeholder = value.codePoints().toArray

  def focus(): Unit = {
    if (!isFocused) {
      if (enabled) {
        isFocused = true
      }
      updateAnimationTargets()
    }
    blinkTimer = 0
  }

  def unfocus(): Unit = {
    if (isFocused) {
      isFocused = false
      updateAnimationTargets()
    }
  }

  // widget management
  // -------------------------------------------------------------------------------------------------------------------
  override def minimumSize: Size2D = Size2D(200, 24)
  override def maximumSize: Size2D = Size2D(Float.PositiveInfinity, 24)

  override def receiveAllMouseEvents = true
  override protected def receiveDragEvents: Boolean = true
  override protected def receiveClickEvents: Boolean = true
  override protected def spamDragEvents: Boolean = false

  private def mouseInBounds: Boolean = clippedBounds.contains(UiHandler.mousePosition)

  protected def font: Font = Font.NormalFont

  private def charWidth(codePoint: Int): Int = font.charWidth(codePoint)

  /**
    * Calculates given text width in pixels.
    * @param from inclusive
    * @param to   exclusive
    */
  // noinspection SameParameterValue
  private def charsWidth(chars: Array[Int], from: Int, to: Int): Int = {
    var width = 0
    for (index <- (from max 0) until (to min chars.length)) {
      width += font.charWidth(chars(index))
    }
    width
  }

  eventHandlers += {
    case MouseEvent(MouseEvent.State.Pressed, _) if enabled =>
      val inBounds = mouseInBounds
      if (isFocused && !inBounds) unfocus()
      if (!isFocused && inBounds) focus()

      if (isFocused) {
        val pos = pixelToCursorPosition(UiHandler.mousePosition.x - bounds.x)
        cursor.position = pos
      }
  }

  eventHandlers += {
    case MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Right) if isFocused && mouseInBounds =>
      val menu = new ContextMenu

      if (selection.nonEmpty) {
        menu.addEntry(ContextMenuEntry("Cut", IconSource.Icons.Cut) { cutSelection() })
        menu.addEntry(ContextMenuEntry("Copy", IconSource.Icons.Copy) { copySelection() })
      }

      if (UiHandler.clipboard.nonEmpty) {
        menu.addEntry(ContextMenuEntry("Paste", IconSource.Icons.Paste) { pasteSelection() })
      }

      if (_text.chars.nonEmpty) {
        if (menu.children.nonEmpty) {
          menu.addSeparator()
        }

        menu.addEntry(ContextMenuEntry("Select all") { selectAll() })
      }

      root.get.contextMenus.open(menu)

    case event @ MouseEvent(MouseEvent.State.Pressed, MouseEvent.Button.Left) if isFocused && mouseInBounds =>
      // we can't use MouseHandler's double/triple click events because they are emitted when the button is released,
      // whereas we want to update the selection when the button is pressed.
      val clickInfo = MouseHandler.ClickInfo(lastLeftClickInfo, UiHandler.mousePosition, event)
      lastLeftClickInfo = Some(clickInfo)

      clickInfo.count match {
        case 1 => selection = None
        case 2 => selectWord()
        case 3 => selectAll()
      }

    case MouseEvent(MouseEvent.State.Pressed, _) if isFocused && mouseInBounds =>
      selection = None
      lastLeftClickInfo = None

    case DragEvent(DragEvent.State.Start | DragEvent.State.Drag, MouseEvent.Button.Left, mouse) if isFocused =>
      val pos = if (mouse.y < bounds.y) {
        0
      } else if (mouse.y > bounds.max.y) {
        _text.chars.length
      } else {
        pixelToCursorPosition(mouse.x - bounds.x)
      }

      extendSelection(pos, prevCursorPosition.value)
      cursor.position = pos

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_LEFT, _) if isFocused =>
      handleKeyMovement(selection match {
        case Some(Selection.Ordered(start, _)) if !KeyEvents.isShiftDown => start
        case _ if KeyEvents.isControlDown =>
          findWordBoundary(cursor.position - 1, forward = false, wordStart = true, punctuationBoundaries = false)
        case _ => cursor.position - 1
      })

      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_RIGHT, _) if isFocused =>
      handleKeyMovement(selection match {
        case Some(Selection.Ordered(_, end)) if !KeyEvents.isShiftDown => end
        case _ if KeyEvents.isControlDown =>
          findWordBoundary(cursor.position + 1, forward = true, wordStart = false, punctuationBoundaries = false)
        case _ => cursor.position + 1
      })

      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_HOME, _) if isFocused =>
      handleKeyMovement(0)
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_END, _) if isFocused =>
      handleKeyMovement(_text.chars.length)
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_BACK, _) if isFocused =>
      if (selection.nonEmpty) {
        deleteSelection()
      } else {
        val start = if (KeyEvents.isControlDown) {
          findWordBoundary(cursor.position - 1, forward = false, wordStart = true, punctuationBoundaries = false)
        } else {
          (cursor.position - 1).clamped(0, _text.chars.length)
        }

        deleteRange(start, cursor.position)
        cursor.position = start
      }

      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_DELETE, _) if isFocused =>
      if (selection.nonEmpty) {
        deleteSelection()
      } else {
        val end = if (KeyEvents.isControlDown) {
          findWordBoundary(cursor.position + 1, forward = true, wordStart = false, punctuationBoundaries = false)
        } else {
          (cursor.position + 1).clamped(0, _text.chars.length)
        }

        deleteRange(cursor.position, end)
      }

      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_A, _)
        if isFocused && KeyEvents.isControlDown =>
      selectAll()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_W, _)
        if isFocused && KeyEvents.isControlDown =>
      selectWord()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_C, _)
        if isFocused && KeyEvents.isControlDown && selection.nonEmpty =>
      copySelection()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_X, _)
        if isFocused && KeyEvents.isControlDown && selection.nonEmpty =>
      cutSelection()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_INSERT, _) if isFocused =>
      pasteSelection()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, Keyboard.KEY_V, _)
        if isFocused && KeyEvents.isControlDown =>
      pasteSelection()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press, Keyboard.KEY_RETURN, _) if isFocused =>
      onConfirm()
      event.consume()

    case event @ KeyEvent(KeyEvent.State.Press | KeyEvent.State.Repeat, _, char) if isFocused && !char.isControl =>
      if (selection.nonEmpty) {
        deleteSelection()
      }

      writeChar(char)
      event.consume()
  }

  /**
    * Will try to find out which cursor position corresponds to the point on the screen.
    * @param x widget-local coordinate
    */
  private def pixelToCursorPosition(x: Float): Int = {
    val absoluteX = x + scroll - 4
    var width = 0
    var pos = 0
    while (width < absoluteX && pos < _text.chars.length) {
      width += charWidth(_text.chars(pos))
      if (width < absoluteX) pos += 1
    }
    pos
  }

  private def extendSelection(position: Int, cursorPosition: Int = cursor.position): Unit = {
    selection = Selection(selection.fold(cursorPosition)(_.start), position)
  }

  private def handleKeyMovement(position: Int): Unit = {
    if (KeyEvents.isShiftDown) {
      extendSelection(position)
    } else {
      selection = None
    }

    cursor.position = position
  }

  private def selectAll(): Unit = {
    selection = Selection(0, _text.chars.length)
  }

  /**
    * Finds a word boundary closest to the `initialPosition` going in the specified direction.
    *
    * If `wordStart` is `true`, the word boundary begins a word. Otherwise, it ends a word (one past the last character
    * of the word). If no boundary is found, the function returns the furthest index in the specified direction.
    *
    * If `punctuationBoundaries` is enabled, a contiguous punctuation sequence is treated as a word for the purposes of
    * boundary search.
    *
    * May return the `initialPosition` if it's already at the boundary.
    */
  private def findWordBoundary(
    initialPosition: Int,
    forward: Boolean,
    wordStart: Boolean,
    punctuationBoundaries: Boolean,
  ): Int = {
    val start = initialPosition.clamped(0, _text.chars.length)

    val indices = if (forward) {
      start.to(_text.chars.length)
    } else {
      start.to(0, -1)
    }

    val isBoundaryCodepoints: (Int, Int) => Boolean = if (wordStart) {
      (prev, current) =>
        (
          isWordCharacter(current) && !isWordCharacter(prev)
          || punctuationBoundaries && isPunctuation(current) && !isPunctuation(prev)
        )
    } else {
      (prev, current) =>
        (
          !isWordCharacter(current) && isWordCharacter(prev)
          || punctuationBoundaries && !isPunctuation(current) && isPunctuation(prev)
        )
    }

    def isBoundary(idx: Int): Boolean = {
      idx == 0 || idx == _text.chars.length || isBoundaryCodepoints(_text.chars(idx - 1), _text.chars(idx))
    }

    indices.find(isBoundary).getOrElse(indices.end)
  }

  private def selectWord(): Unit = {
    val from = findWordBoundary(cursor.position, forward = false, wordStart = true, punctuationBoundaries = true)
    val to = findWordBoundary(from + 1, forward = true, wordStart = false, punctuationBoundaries = true)
    selection = Selection(from, to)
  }

  private def copySelection(): Unit = {
    UiHandler.clipboard = selectedText
  }

  private def cutSelection(): Unit = {
    UiHandler.clipboard = selectedText
    deleteSelection()
  }

  private def pasteSelection(): Unit = {
    if (selection.nonEmpty) deleteSelection()
    writeString(UiHandler.clipboard)
  }

  private def deleteSelection(): Unit = {
    for (Selection.Ordered(start, end) <- selection) {
      deleteRange(start, end)
      cursor.position = start
    }

    selection = None
  }

  private def deleteRange(start: Int, end: Int): Unit = {
    _text.chars = _text.chars.take(start) ++ _text.chars.drop(end)
  }

  private def writeString(string: String): Unit = {
    val (lhs, rhs) = _text.chars.splitAt(cursor.position)
    val array = string.codePoints().toArray
    _text.chars = lhs ++ array ++ rhs
    cursor.position += array.length
  }

  private def writeChar(codePoint: Int): Unit = {
    val (lhs, rhs) = _text.chars.splitAt(cursor.position)
    _text.chars = lhs ++ Array(codePoint) ++ rhs
    cursor.position += 1
  }

  /**
    * Apply a set of corrections to the scroll to make sure the cursor and text stay visible
    */
  private def adjustScroll(): Unit = {
    // make cursor visible
    if (cursorOffset < scroll) scroll = cursorOffset
    if (cursorOffset - scroll > size.width - 16) scroll = cursorOffset - size.width + 16
    // apply pressure from the left (to maximize visible text, for nicer editing experience)
    val fullTextWidth = charsWidth(_text.chars, 0, _text.chars.length)
    val areaWidth = size.width - 16
    if (fullTextWidth > areaWidth && fullTextWidth - scroll < areaWidth) scroll = fullTextWidth - areaWidth
  }

  override def update(): Unit = {
    super.update()

    // process state changes
    if (_text.changed()) {
      onInput(text)
      updateAnimationTargets()
      adjustScroll()
    }

    if (enabledRegister.update()) {
      updateAnimationTargets()
    }
    if (isFocused && !enabled) {
      unfocus()
    }

    // update everything
    prevCursorPosition.update()
    foregroundAnimation.update()
    borderAnimation.update()
    blinkTimer = (blinkTimer + UiHandler.dt) % CursorBlinkTime
  }

  private def updateAnimationTargets(): Unit = {
    foregroundAnimation.goto(targetForegroundColor)
    borderAnimation.goto(targetBorderColor)
  }

  private def targetBorderColor: Color = ColorScheme(
    if (validator(text)) {
      if (isFocused) "TextInputBorderFocused"
      else if (!enabled) "TextInputBorderDisabled"
      else "TextInputBorder"
    } else {
      if (isFocused) "TextInputBorderErrorFocused"
      else if (!enabled) "TextInputBorderErrorDisabled"
      else "TextInputBorderError"
    }
  )
  private def targetForegroundColor: Color = ColorScheme(
    if (!enabled) "TextInputForegroundDisabled"
    else "TextInputForeground"
  )

  override def draw(g: Graphics): Unit = {
    g.rect(bounds, hoverAnimation.color)
    DrawUtils.ring(g, position.x, position.y, size.width, size.height, thickness = 2, borderAnimation.color)

    g.setScissor(position.x + 4, position.y, size.width - 8f, size.height)

    for ((start, end) <- selectionOffsets) {
      val width = end - start
      g.rect(position.x + start + 8 - scroll, position.y + 4, width, size.height - 8, BackgroundSelectedColor)
    }

    g.background = Color.Transparent
    val foreground = if (_text.chars.nonEmpty || isFocused) foregroundAnimation.color else PlaceholderForegroundColor
    g.foreground = foreground

    var charOffset = 0
    val charsToDisplay = if (_text.chars.nonEmpty || isFocused) _text.chars else placeholder
    for (char <- charsToDisplay) {
      for ((start, end) <- selectionOffsets) {
        g.foreground = if (charOffset >= start && charOffset < end) ForegroundSelectedColor else foreground
      }

      g.char(position.x + 8 + charOffset - scroll, position.y + 4, char)
      charOffset += g.font.charWidth(char)
    }

    if (isFocused && blinkTimer < CursorBlinkTime * 0.5f) {
      g.rect(position.x + 7 + cursorOffset - scroll, position.y + 4, 2, 16, borderAnimation.color)
    }
  }
}

object TextInput {
  private val PunctuationCategories = Array[Int](
    Character.CONNECTOR_PUNCTUATION,
    Character.DASH_PUNCTUATION,
    Character.END_PUNCTUATION,
    Character.FINAL_QUOTE_PUNCTUATION,
    Character.INITIAL_QUOTE_PUNCTUATION,
    Character.OTHER_PUNCTUATION,
    Character.START_PUNCTUATION,
  )

  private def isPunctuation(codepoint: Int): Boolean = {
    !isWordCharacter(codepoint) && PunctuationCategories.contains(Character.getType(codepoint))
  }
  
  private def isWordCharacter(codepoint: Int): Boolean = {
    codepoint == '_' || Character.isLetterOrDigit(codepoint)
  }

  class Text(initialValue: Array[Int]) extends BaseWatcher(initialValue) {
    def chars: Array[Int] = value
    def chars_=(newValue: Array[Int]): Unit = value = newValue
  }

  class Cursor(initialValue: Int = 0) extends BaseWatcher(initialValue) {
    var desiredPosition: Int = initialValue

    def position: Int = value

    def position_=(newValue: Int): Unit = {
      value = newValue
      desiredPosition = newValue
    }
  }

  case class Selection(start: Int, end: Int) {
    require(start != end)
  }

  object Selection {
    def apply(start: Int, end: Int): Option[Selection] = {
      Option.when(start != end) {
        new Selection(start, end)
      }
    }

    object Ordered {
      def unapply(selection: Selection): Some[(Int, Int)] = {
        val Selection(start, end) = selection

        Some((start min end, start max end))
      }
    }
  }
}
